Vectrum
Sign In
105 lines · 3.8 KB · JavaScript
// 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));
  },
);