// OAuth 2.1 for MCP clients (AI agents): applications register themselves (RFC 7591), a user
// approves one on /oauth/authorize, and it gets an access token for /mcp plus a refresh token.
// Only hashes of codes and tokens are stored. See lib/oauth.ts.
const text = (name, opts = {}) => ({ type: "text", name, max: 1000, ...opts });
const time = (name, opts = {}) => ({ type: "number", name, onlyInt: true, min: 0, ...opts });
const hash = (name, opts = {}) => text(name, { max: 64, ...opts });
const permission = () => ({ type: "select", name: "permission", values: ["read", "write"], maxSelect: 1, required: true });
const parent = (name, collection) => ({
type: "relation",
name,
collectionId: collection.id,
maxSelect: 1,
required: true,
cascadeDelete: true,
});
function create(app, name, fields, indexes = []) {
const collection = new Collection({
type: "base",
name,
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields,
indexes,
});
app.save(collection);
return collection;
}
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
const repos = app.findCollectionByNameOrId("repos");
// The repositories a grant covers, unless all_repos.
const repoList = () => ({ type: "relation", name: "repos", collectionId: repos.id, maxSelect: 1000, cascadeDelete: false });
// Public clients: the record id is the client_id; there are no client secrets. Anyone can
// register one, so ones nobody approved within a day are deleted again.
const clients = create(
app,
"oauth_clients",
[
text("name", { required: true, max: 100 }),
text("client_uri", { max: 500 }),
{ type: "json", name: "redirect_uris", maxSize: 25000 },
time("created_at", { required: true }),
time("last_authorized_at"),
],
["CREATE INDEX idx_oauth_clients_created ON oauth_clients (created_at)"],
);
// Authorization codes live a few minutes and are deleted when exchanged.
create(
app,
"oauth_codes",
[
hash("code_hash", { required: true }),
parent("client", clients),
parent("user", users),
text("redirect_uri", { required: true, max: 2000 }),
// base64url(sha256(code_verifier)), PKCE S256
text("code_challenge", { required: true, max: 128 }),
permission(),
{ type: "bool", name: "all_repos" },
repoList(),
time("expires_at", { required: true }),
],
["CREATE UNIQUE INDEX idx_oauth_codes_hash ON oauth_codes (code_hash)"],
);
// What a user approved for a client, with its current access and refresh tokens. Refreshing
// replaces both; presenting the previous refresh token again revokes the grant.
create(
app,
"oauth_grants",
[
parent("client", clients),
parent("user", users),
permission(),
{ type: "bool", name: "all_repos" },
repoList(),
hash("access_hash", { required: true }),
time("access_expires_at", { required: true }),
hash("refresh_hash", { required: true }),
time("refresh_expires_at", { required: true }),
hash("previous_refresh_hash"),
time("created_at", { required: true }),
time("last_used_at"),
],
[
"CREATE UNIQUE INDEX idx_oauth_grants_access ON oauth_grants (access_hash)",
"CREATE UNIQUE INDEX idx_oauth_grants_refresh ON oauth_grants (refresh_hash)",
"CREATE INDEX idx_oauth_grants_previous ON oauth_grants (previous_refresh_hash)",
"CREATE INDEX idx_oauth_grants_user ON oauth_grants (user)",
],
);
},
(app) => {
for (const name of ["oauth_grants", "oauth_codes", "oauth_clients"]) app.delete(app.findCollectionByNameOrId(name));
},
);