// 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)); }, );