From 8fcfff94a4f30c28dca2f92d18a8091a8e216cfc Mon Sep 17 00:00:00 2001
From: Lorenzo Corallo
Date: Sat, 24 Oct 2026 18:01:06 +0200
Subject: [PATCH 1/2] feat(mcp): add an MCP server for AI agents, gated by
idp:mcp
Agents such as Claude Code and Codex connect to /api/mcp, sign in through
OAuth with dynamic client registration limited to agents, and act with the
person's own permissions. Creating applications now inserts the client under
the RBAC write lock instead of through the provider's session-only endpoint.
---
README.md | 77 +-
drizzle/0022_idp_mcp.sql | 6 +
drizzle/meta/0022_snapshot.json | 2475 +++++++++++++++++
drizzle/meta/_journal.json | 7 +
package.json | 1 +
pnpm-lock.yaml | 20 +
src/auth/account.functions.ts | 25 +-
src/auth/data-retention.ts | 7 +-
src/auth/identity.integration.test.ts | 44 +-
src/auth/index.ts | 80 +-
src/auth/mcp-clients.test.ts | 135 +
src/auth/mcp-clients.ts | 135 +
src/auth/mcp-connections.ts | 104 +
src/auth/mcp-diagnostics.test.ts | 214 ++
src/auth/mcp-diagnostics.ts | 365 +++
src/auth/mcp-resource.ts | 64 +
src/auth/mcp-server.ts | 860 ++++++
src/auth/mcp-token.ts | 74 +
src/auth/oidc-clients.ts | 14 +-
src/auth/oidc-registry.ts | 123 +-
src/auth/oidc.functions.ts | 97 +-
src/auth/rbac.test.ts | 3 +-
src/auth/rbac.ts | 7 +
src/components/account/mcp-panel.tsx | 104 +
src/components/account/use-account-actions.ts | 17 +-
src/routeTree.gen.ts | 90 +
.../oauth-authorization-server.$.ts | 23 +
.../oauth-protected-resource.$.ts | 34 +
.../[.]well-known/openid-configuration.$.ts | 23 +
src/routes/api/mcp.ts | 89 +
src/routes/consent.tsx | 24 +
src/routes/index.tsx | 2 +
32 files changed, 5137 insertions(+), 206 deletions(-)
create mode 100644 drizzle/0022_idp_mcp.sql
create mode 100644 drizzle/meta/0022_snapshot.json
create mode 100644 src/auth/mcp-clients.test.ts
create mode 100644 src/auth/mcp-clients.ts
create mode 100644 src/auth/mcp-connections.ts
create mode 100644 src/auth/mcp-diagnostics.test.ts
create mode 100644 src/auth/mcp-diagnostics.ts
create mode 100644 src/auth/mcp-resource.ts
create mode 100644 src/auth/mcp-server.ts
create mode 100644 src/auth/mcp-token.ts
create mode 100644 src/components/account/mcp-panel.tsx
create mode 100644 src/routes/[.]well-known/oauth-authorization-server.$.ts
create mode 100644 src/routes/[.]well-known/oauth-protected-resource.$.ts
create mode 100644 src/routes/[.]well-known/openid-configuration.$.ts
create mode 100644 src/routes/api/mcp.ts
diff --git a/README.md b/README.md
index 54edaeb..4b40ad0 100644
--- a/README.md
+++ b/README.md
@@ -158,22 +158,23 @@ when that is what you mean.
### Permissions the identity provider defines itself
Administering this service is expressed as permissions like any other capability, so it can
-be delegated to a role instead of being wired to a single group. These ten always exist
+be delegated to a role instead of being wired to a single group. These eleven always exist
and can never be created, deleted, or rekeyed, because the code checks for these exact
keys; which roles carry them is entirely up to you.
-| Permission | Covers |
-| ------------------------ | ---------------------------------------------------------- |
-| `idp:people:read` | Searching the people registered here |
-| `idp:users:read` | Browsing the user directory at `/users` |
-| `idp:users:delete` | Permanently deleting someone's account |
-| `idp:permissions:read` | Seeing permissions in the `/access` section |
-| `idp:permissions:write` | Creating, changing, and deleting permissions |
-| `idp:roles:read` | Seeing roles, what they grant, and who holds them |
-| `idp:roles:write` | Creating, changing, and deleting roles |
-| `idp:roles:assign` | Giving roles to people and taking them away |
-| `idp:applications:read` | Seeing the OIDC applications at `/applications` |
-| `idp:applications:write` | Registering and editing applications, and rotating secrets |
+| Permission | Covers |
+| ------------------------ | ------------------------------------------------------------ |
+| `idp:people:read` | Searching the people registered here |
+| `idp:users:read` | Browsing the user directory at `/users` |
+| `idp:users:delete` | Permanently deleting someone's account |
+| `idp:permissions:read` | Seeing permissions in the `/access` section |
+| `idp:permissions:write` | Creating, changing, and deleting permissions |
+| `idp:roles:read` | Seeing roles, what they grant, and who holds them |
+| `idp:roles:write` | Creating, changing, and deleting roles |
+| `idp:roles:assign` | Giving roles to people and taking them away |
+| `idp:applications:read` | Seeing the OIDC applications at `/applications` |
+| `idp:applications:write` | Registering and editing applications, and rotating secrets |
+| `idp:mcp` | Connecting an AI agent through the [MCP server](#mcp-server) |
They use the permission hierarchy themselves: each `write` grants its `read`,
`idp:roles:assign` grants `idp:roles:read` and `idp:people:read` so an assigner can see the
@@ -321,7 +322,7 @@ keys cannot change; renaming a custom role's key breaks policies that match the
Managing applications needs the `idp:applications:write` permission, so it can be given to any role. Master Admin holds it only through explicit deployment configuration. To use a Microsoft 365 administrators group distinct from Soci, set `PN_ENTRA_OIDC_ADMIN_GROUP_ID` to that group's object ID: only its direct members, checked through the same Graph credentials, keep it. Graph answers are cached for at most 60 seconds from lookup start per user; a failed check denies access instead of caching. `IDP_ADMIN_USER_IDS` remains a break-glass allowlist of local user IDs that always pass. Being a socio never confers administration by itself.
-Dynamic registration is disabled. Client-credentials grants are available when both OAuth resource identifiers are configured. Administrators manage every client at `/applications`. New applications go through a step-by-step wizard (Basics, Usage, Authentication, Access, then a review) with a summary that fills in as each step is completed; existing ones are edited on a settings page with a save bar. The app either signs people in, acts as itself (client credentials), or both, and that choice decides the credentials it may use: a client secret (shown once), a private key (a public Ed25519 or P-256 JWKS for `private_key_jwt`), or, for apps that only sign people in, none (public, PKCE only). Redirect URIs, the scopes people are asked for, and the PoliNetwork API scopes granted for signed-in people and for the app itself are chosen separately. Resource links follow the granted API scopes automatically. Everything except the client ID can be changed after creation, including the credential type: switching to a secret issues a new one, shown once, and switching away from a secret or key revokes it. Administrators can also rotate secrets, pause sign-ins by disabling an app, skip the consent screen for first-party apps, and delete apps. All administrators share one client pool (the plugin's `clientReference` is a fixed value), so clients are not tied to whoever created them. Redirect URIs follow the provider's rules, chosen per application as "Public https URLs only" (`application_type: web`) or "Also localhost and app schemes" (`native`: `http://localhost`, `http://127.0.0.1`, `http://[::1]`, or a reverse-domain custom scheme). Server functions in `src/auth/oidc.functions.ts` back the pages, including secret rotation and deletion. API access (backend and `idp:access:read` scopes, the client-credentials grant, and resource links) requires Master Admin, and so does any change to a client that has it. Every change to an existing client checks that rule and writes in one transaction that holds the RBAC write lock, so a writer's rotation or deletion either finishes before a concurrent promotion or is refused after it. The provider's own client mutation endpoints (`/oauth2/create-client`, `/oauth2/update-client`, `/oauth2/delete-client`, `/oauth2/client/rotate-secret`) are disabled over HTTP and refused to server-side callers, because they check and write in separate statements outside that lock; resources are linked only by saving an application.
+Dynamic registration is open to AI agents only, for the [MCP server](#mcp-server). Client-credentials grants are available when both OAuth resource identifiers are configured. Administrators manage every client at `/applications`. New applications go through a step-by-step wizard (Basics, Usage, Authentication, Access, then a review) with a summary that fills in as each step is completed; existing ones are edited on a settings page with a save bar. The app either signs people in, acts as itself (client credentials), or both, and that choice decides the credentials it may use: a client secret (shown once), a private key (a public Ed25519 or P-256 JWKS for `private_key_jwt`), or, for apps that only sign people in, none (public, PKCE only). Redirect URIs, the scopes people are asked for, and the PoliNetwork API scopes granted for signed-in people and for the app itself are chosen separately. Resource links follow the granted API scopes automatically. Everything except the client ID can be changed after creation, including the credential type: switching to a secret issues a new one, shown once, and switching away from a secret or key revokes it. Administrators can also rotate secrets, pause sign-ins by disabling an app, skip the consent screen for first-party apps, and delete apps. All administrators share one client pool (the plugin's `clientReference` is a fixed value), so clients are not tied to whoever created them. Redirect URIs follow the provider's rules, chosen per application as "Public https URLs only" (`application_type: web`) or "Also localhost and app schemes" (`native`: `http://localhost`, `http://127.0.0.1`, `http://[::1]`, or a reverse-domain custom scheme). Server functions in `src/auth/oidc.functions.ts` back the pages, including secret rotation and deletion. API access (backend and `idp:access:read` scopes, the client-credentials grant, and resource links) requires Master Admin, and so does any change to a client that has it. Creating a client and every change to an existing one check that rule and write in one transaction that holds the RBAC write lock, so a writer's rotation or deletion either finishes before a concurrent promotion or is refused after it. New clients are inserted directly, in the row format the provider uses, because its admin create endpoint needs a browser session and writes outside the lock. The provider's own client mutation endpoints (`/oauth2/create-client`, `/oauth2/update-client`, `/oauth2/delete-client`, `/oauth2/client/rotate-secret`) are disabled over HTTP and refused to server-side callers, because they check and write in separate statements outside that lock; resources are linked only by saving an application.
### Phase 1 backend access snapshot
@@ -337,6 +338,54 @@ During a sign-in the login page names the requesting application. The consent pa
Future consumers should use authorization code with PKCE, validate token signatures, issuer, audience, and expiration, and request `polinetwork:identity` only when needed. APIs must check their own permissions. An ID token or a linked Telegram ID alone is not permission to moderate a group.
+## MCP server
+
+`/api/mcp` is an MCP server, so an AI agent such as Claude Code or Codex can read and change this identity provider for you: debug an application that cannot sign people in, register or edit applications, and set up roles, permissions and who holds them. It only works for people holding `idp:mcp`, and the agent can only do what that person could do on the website.
+
+### Connect an agent
+
+Your account page shows the server URL and these commands when you hold `idp:mcp`. For production:
+
+| Agent | Command |
+| ----------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| Claude Code | `claude mcp add --scope user --transport http polinetwork-auth https://auth.polinetwork.org/api/mcp`, then `/mcp` to sign in |
+| Codex | `codex mcp add polinetwork-auth --url https://auth.polinetwork.org/api/mcp` (signs in right away) |
+| Gemini CLI | `gemini mcp add --scope user --transport http polinetwork-auth https://auth.polinetwork.org/api/mcp` |
+| VS Code | `code --add-mcp '{"name":"polinetwork-auth","type":"http","url":"https://auth.polinetwork.org/api/mcp"}'` |
+
+Claude Desktop, claude.ai and ChatGPT add it as a custom connector with the same URL. The agent opens a browser, you sign in and approve it once, and it stays connected: it refreshes its access every few minutes, survives signing out of this website, and stops after 30 days without use. Cursor does not work yet: it registers a `cursor://` callback that is not a valid app scheme, so sign-in is refused.
+
+### What an agent can do
+
+Every tool calls the same code as the matching page, which checks the permission again, keeps the bounded delegation rules, takes the RBAC write lock, and writes the audit log under your name. Tools you have no permission for are not offered.
+
+| Permission | Tools |
+| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `idp:mcp` | `whoami`, `get_server_info` (endpoints, token formats, rules), `inspect_token` (decode and verify a JWT) |
+| `idp:applications:read` | `list_applications`, `get_application`, `diagnose_application` (finds bad settings and, given the authorization URL an app sends, says what `/oauth2/authorize` would answer and why) |
+| `idp:applications:write` | `create_application`, `update_application`, `rotate_application_secret`, `delete_application` |
+| `idp:people:read` | `search_people` |
+| `idp:users:read` | `list_users`, `get_user`; with `idp:roles:read` also `explain_access` (why someone holds a permission) |
+| `idp:roles:read` | `list_roles`, `get_role` |
+| `idp:permissions:read` | `list_permissions` |
+| `idp:roles:write` | `create_role`, `update_role`, `delete_role` |
+| `idp:roles:assign` | `assign_role`, `unassign_role` |
+| `idp:permissions:write` | `create_permission`, `update_permission`, `delete_permission` |
+| Master Admin | `list_audit_events` |
+
+Deleting people is left to the website. Deletes ask the agent to repeat the name or key. New client secrets go back to the agent once, like on the page, so it can hand them over.
+
+### How sign-in works
+
+The server follows the MCP authorization spec. `/.well-known/oauth-protected-resource/api/mcp` names this identity provider as its authorization server, and the authorization server metadata is also served at `/.well-known/oauth-authorization-server/api/auth` and `/.well-known/openid-configuration/api/auth`, where agents look first. Agents register themselves through dynamic client registration (`/api/auth/oauth2/register`), which is open but only to agents:
+
+- A registration must redirect to `http://localhost`, `http://127.0.0.1` or `http://[::1]` (any port), or exactly to a hosted agent's callback (`claude.ai`, `claude.com`, `chatgpt.com`, `vscode.dev`). Someone who tricks you into approving their client cannot collect the code on their own machine.
+- It may use only the authorization code grant with refresh tokens, as a public client or with a secret, never with keys. A request with a browser session is refused: applications for people are registered from `/applications`.
+- Its scopes are limited to `idp:mcp`, `offline_access` and `openid`, and it is linked to the MCP server alone, so its tokens are useless anywhere else. Agents that ask for more get only these.
+- Its logo, links and logout addresses are dropped, because PoliNetwork would seem to vouch for them. The consent page shows its name and where it redirects.
+
+Tokens are EdDSA JWTs addressed to `/api/mcp`, valid five minutes. Every request checks the token, that the agent's client still exists with your consent, and that you still hold `idp:mcp`, so taking the permission away or disconnecting the agent from your account page ends access on the next request. People without `idp:mcp` are stopped on the consent page. Registrations that never got a refresh token are deleted after a day, and so are agents whose last refresh token expired and was cleaned up. Registration is rate-limited to five a minute per address.
+
## Migration work before cutover
Inspection found backend auth in `../backend/src/auth/index.ts`, with custom email OTP, passkeys, shared subdomain cookies, and custom Telegram linking. `../admin` reads `user.telegramId` and calls backend Telegram permission routes. `../group-bot` also uses those backend assignments.
diff --git a/drizzle/0022_idp_mcp.sql b/drizzle/0022_idp_mcp.sql
new file mode 100644
index 0000000..6a8da30
--- /dev/null
+++ b/drizzle/0022_idp_mcp.sql
@@ -0,0 +1,6 @@
+-- Using the MCP server is delegated like every other administrative right. It confers nothing
+-- by itself: an agent connected through it acts with the person's other permissions. No role
+-- is given it; Master Admin covers the new key through its wildcard.
+INSERT INTO "permission" ("id", "key", "name", "description", "managed") VALUES
+ ('managed-permission-idp-mcp', 'idp:mcp', 'Use the MCP server', 'Connect an AI agent to this identity provider. The agent can only do what the person''s other permissions allow.', true)
+ON CONFLICT ("key") DO NOTHING;
diff --git a/drizzle/meta/0022_snapshot.json b/drizzle/meta/0022_snapshot.json
new file mode 100644
index 0000000..60ea3f8
--- /dev/null
+++ b/drizzle/meta/0022_snapshot.json
@@ -0,0 +1,2475 @@
+{
+ "id": "7795fbb9-3c55-41e8-8e86-8bfa10bf099e",
+ "prevId": "36d209bc-7901-4b62-9fa6-10bff23ed011",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "account_issuer_subject_uidx": {
+ "name": "account_issuer_subject_uidx",
+ "columns": [
+ {
+ "expression": "issuer",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.jwks": {
+ "name": "jwks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "private_key": {
+ "name": "private_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "alg": {
+ "name": "alg",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "crv": {
+ "name": "crv",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_access_token": {
+ "name": "oauth_access_token",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reference_id": {
+ "name": "reference_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "authorization_code_id": {
+ "name": "authorization_code_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resources": {
+ "name": "resources",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "requested_user_info_claims": {
+ "name": "requested_user_info_claims",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_id": {
+ "name": "refresh_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked": {
+ "name": "revoked",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confirmation": {
+ "name": "confirmation",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "oauthAccessToken_clientId_idx": {
+ "name": "oauthAccessToken_clientId_idx",
+ "columns": [
+ {
+ "expression": "client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthAccessToken_sessionId_idx": {
+ "name": "oauthAccessToken_sessionId_idx",
+ "columns": [
+ {
+ "expression": "session_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthAccessToken_userId_idx": {
+ "name": "oauthAccessToken_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthAccessToken_authorizationCodeId_idx": {
+ "name": "oauthAccessToken_authorizationCodeId_idx",
+ "columns": [
+ {
+ "expression": "authorization_code_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthAccessToken_refreshId_idx": {
+ "name": "oauthAccessToken_refreshId_idx",
+ "columns": [
+ {
+ "expression": "refresh_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oauth_access_token_client_id_oauth_client_client_id_fk": {
+ "name": "oauth_access_token_client_id_oauth_client_client_id_fk",
+ "tableFrom": "oauth_access_token",
+ "tableTo": "oauth_client",
+ "columnsFrom": ["client_id"],
+ "columnsTo": ["client_id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_access_token_session_id_session_id_fk": {
+ "name": "oauth_access_token_session_id_session_id_fk",
+ "tableFrom": "oauth_access_token",
+ "tableTo": "session",
+ "columnsFrom": ["session_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "oauth_access_token_user_id_user_id_fk": {
+ "name": "oauth_access_token_user_id_user_id_fk",
+ "tableFrom": "oauth_access_token",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": {
+ "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk",
+ "tableFrom": "oauth_access_token",
+ "tableTo": "oauth_refresh_token",
+ "columnsFrom": ["refresh_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "oauth_access_token_token_unique": {
+ "name": "oauth_access_token_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_client": {
+ "name": "oauth_client",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_secret": {
+ "name": "client_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_discovery_id": {
+ "name": "client_discovery_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "disabled": {
+ "name": "disabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "skip_consent": {
+ "name": "skip_consent",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enable_end_session": {
+ "name": "enable_end_session",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "subject_type": {
+ "name": "subject_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_credentials_scopes": {
+ "name": "client_credentials_scopes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{}'"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uri": {
+ "name": "uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon_dark": {
+ "name": "icon_dark",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contacts": {
+ "name": "contacts",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tos": {
+ "name": "tos",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "policy": {
+ "name": "policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "software_id": {
+ "name": "software_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "software_version": {
+ "name": "software_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "software_statement": {
+ "name": "software_statement",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "redirect_uris": {
+ "name": "redirect_uris",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "post_logout_redirect_uris": {
+ "name": "post_logout_redirect_uris",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "backchannel_logout_uri": {
+ "name": "backchannel_logout_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "backchannel_logout_session_required": {
+ "name": "backchannel_logout_session_required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_endpoint_auth_method": {
+ "name": "token_endpoint_auth_method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "application_type": {
+ "name": "application_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "jwks": {
+ "name": "jwks",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "jwks_uri": {
+ "name": "jwks_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "grant_types": {
+ "name": "grant_types",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "response_types": {
+ "name": "response_types",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "require_pkce": {
+ "name": "require_pkce",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dpop_bound_access_tokens": {
+ "name": "dpop_bound_access_tokens",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "reference_id": {
+ "name": "reference_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "oauthClient_userId_idx": {
+ "name": "oauthClient_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oauth_client_user_id_user_id_fk": {
+ "name": "oauth_client_user_id_user_id_fk",
+ "tableFrom": "oauth_client",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "oauth_client_client_id_unique": {
+ "name": "oauth_client_client_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["client_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_client_assertion": {
+ "name": "oauth_client_assertion",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_client_resource": {
+ "name": "oauth_client_resource",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource_id": {
+ "name": "resource_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "oauthClientResource_clientId_resourceId_uidx": {
+ "name": "oauthClientResource_clientId_resourceId_uidx",
+ "columns": [
+ {
+ "expression": "client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "resource_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthClientResource_clientId_idx": {
+ "name": "oauthClientResource_clientId_idx",
+ "columns": [
+ {
+ "expression": "client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthClientResource_resourceId_idx": {
+ "name": "oauthClientResource_resourceId_idx",
+ "columns": [
+ {
+ "expression": "resource_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oauth_client_resource_client_id_oauth_client_client_id_fk": {
+ "name": "oauth_client_resource_client_id_oauth_client_client_id_fk",
+ "tableFrom": "oauth_client_resource",
+ "tableTo": "oauth_client",
+ "columnsFrom": ["client_id"],
+ "columnsTo": ["client_id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_client_resource_resource_id_oauth_resource_identifier_fk": {
+ "name": "oauth_client_resource_resource_id_oauth_resource_identifier_fk",
+ "tableFrom": "oauth_client_resource",
+ "tableTo": "oauth_resource",
+ "columnsFrom": ["resource_id"],
+ "columnsTo": ["identifier"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_consent": {
+ "name": "oauth_consent",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reference_id": {
+ "name": "reference_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resources": {
+ "name": "resources",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "requested_user_info_claims": {
+ "name": "requested_user_info_claims",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "oauthConsent_clientId_idx": {
+ "name": "oauthConsent_clientId_idx",
+ "columns": [
+ {
+ "expression": "client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthConsent_userId_idx": {
+ "name": "oauthConsent_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oauth_consent_client_id_oauth_client_client_id_fk": {
+ "name": "oauth_consent_client_id_oauth_client_client_id_fk",
+ "tableFrom": "oauth_consent",
+ "tableTo": "oauth_client",
+ "columnsFrom": ["client_id"],
+ "columnsTo": ["client_id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_consent_user_id_user_id_fk": {
+ "name": "oauth_consent_user_id_user_id_fk",
+ "tableFrom": "oauth_consent",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_refresh_token": {
+ "name": "oauth_refresh_token",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reference_id": {
+ "name": "reference_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "authorization_code_id": {
+ "name": "authorization_code_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resources": {
+ "name": "resources",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "requested_user_info_claims": {
+ "name": "requested_user_info_claims",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked": {
+ "name": "revoked",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rotated_at": {
+ "name": "rotated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rotation_replay_response": {
+ "name": "rotation_replay_response",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rotation_replay_expires_at": {
+ "name": "rotation_replay_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "auth_time": {
+ "name": "auth_time",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confirmation": {
+ "name": "confirmation",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "oauthRefreshToken_clientId_idx": {
+ "name": "oauthRefreshToken_clientId_idx",
+ "columns": [
+ {
+ "expression": "client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthRefreshToken_sessionId_idx": {
+ "name": "oauthRefreshToken_sessionId_idx",
+ "columns": [
+ {
+ "expression": "session_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthRefreshToken_userId_idx": {
+ "name": "oauthRefreshToken_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oauthRefreshToken_authorizationCodeId_idx": {
+ "name": "oauthRefreshToken_authorizationCodeId_idx",
+ "columns": [
+ {
+ "expression": "authorization_code_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oauth_refresh_token_client_id_oauth_client_client_id_fk": {
+ "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk",
+ "tableFrom": "oauth_refresh_token",
+ "tableTo": "oauth_client",
+ "columnsFrom": ["client_id"],
+ "columnsTo": ["client_id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oauth_refresh_token_session_id_session_id_fk": {
+ "name": "oauth_refresh_token_session_id_session_id_fk",
+ "tableFrom": "oauth_refresh_token",
+ "tableTo": "session",
+ "columnsFrom": ["session_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "oauth_refresh_token_user_id_user_id_fk": {
+ "name": "oauth_refresh_token_user_id_user_id_fk",
+ "tableFrom": "oauth_refresh_token",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "oauth_refresh_token_token_unique": {
+ "name": "oauth_refresh_token_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oauth_resource": {
+ "name": "oauth_resource",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token_ttl": {
+ "name": "access_token_ttl",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_ttl": {
+ "name": "refresh_token_ttl",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "signing_algorithm": {
+ "name": "signing_algorithm",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "signing_key_id": {
+ "name": "signing_key_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "allowed_scopes": {
+ "name": "allowed_scopes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "custom_claims": {
+ "name": "custom_claims",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dpop_bound_access_tokens_required": {
+ "name": "dpop_bound_access_tokens_required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "disabled": {
+ "name": "disabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "policy_version": {
+ "name": "policy_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 1
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "oauth_resource_identifier_unique": {
+ "name": "oauth_resource_identifier_unique",
+ "nullsNotDistinct": false,
+ "columns": ["identifier"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.passkey": {
+ "name": "passkey",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "counter": {
+ "name": "counter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "backed_up": {
+ "name": "backed_up",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "transports": {
+ "name": "transports",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "aaguid": {
+ "name": "aaguid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "passkey_userId_idx": {
+ "name": "passkey_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "passkey_user_id_user_id_fk": {
+ "name": "passkey_user_id_user_id_fk",
+ "tableFrom": "passkey",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "passkey_credential_id_unique": {
+ "name": "passkey_credential_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["credential_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.rate_limit": {
+ "name": "rate_limit",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_request": {
+ "name": "last_request",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "rate_limit_key_unique": {
+ "name": "rate_limit_key_unique",
+ "nullsNotDistinct": false,
+ "columns": ["key"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "passkey_prompt_declined_at": {
+ "name": "passkey_prompt_declined_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.identity_evidence": {
+ "name": "identity_evidence",
+ "schema": "",
+ "columns": {
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "states": {
+ "name": "states",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "valid_until": {
+ "name": "valid_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "telegram_id": {
+ "name": "telegram_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "telegram_username": {
+ "name": "telegram_username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "identity_evidence_telegram_id_uidx": {
+ "name": "identity_evidence_telegram_id_uidx",
+ "columns": [
+ {
+ "expression": "telegram_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"identity_evidence\".\"telegram_id\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "identity_evidence_issuer_subject_pk": {
+ "name": "identity_evidence_issuer_subject_pk",
+ "columns": ["issuer", "subject"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.student_verification_challenge": {
+ "name": "student_verification_challenge",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code_hash": {
+ "name": "code_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_sent_at": {
+ "name": "last_sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "studentVerificationChallenge_email_uidx": {
+ "name": "studentVerificationChallenge_email_uidx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "studentVerificationChallenge_expiresAt_idx": {
+ "name": "studentVerificationChallenge_expiresAt_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "student_verification_challenge_user_id_user_id_fk": {
+ "name": "student_verification_challenge_user_id_user_id_fk",
+ "tableFrom": "student_verification_challenge",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.permission": {
+ "name": "permission",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "managed": {
+ "name": "managed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "permission_key_uidx": {
+ "name": "permission_key_uidx",
+ "columns": [
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.permission_implication": {
+ "name": "permission_implication",
+ "schema": "",
+ "columns": {
+ "permission_id": {
+ "name": "permission_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "implied_permission_id": {
+ "name": "implied_permission_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "permissionImplication_implied_idx": {
+ "name": "permissionImplication_implied_idx",
+ "columns": [
+ {
+ "expression": "implied_permission_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "permission_implication_permission_id_permission_id_fk": {
+ "name": "permission_implication_permission_id_permission_id_fk",
+ "tableFrom": "permission_implication",
+ "tableTo": "permission",
+ "columnsFrom": ["permission_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "permission_implication_implied_permission_id_permission_id_fk": {
+ "name": "permission_implication_implied_permission_id_permission_id_fk",
+ "tableFrom": "permission_implication",
+ "tableTo": "permission",
+ "columnsFrom": ["implied_permission_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "permission_implication_permission_id_implied_permission_id_pk": {
+ "name": "permission_implication_permission_id_implied_permission_id_pk",
+ "columns": ["permission_id", "implied_permission_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.rbac_audit_event": {
+ "name": "rbac_audit_event",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "operation": {
+ "name": "operation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "before": {
+ "name": "before",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "after": {
+ "name": "after",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.role": {
+ "name": "role",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "managed": {
+ "name": "managed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "source_state": {
+ "name": "source_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updatedAt": {
+ "name": "updatedAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "role_key_uidx": {
+ "name": "role_key_uidx",
+ "columns": [
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.role_parent": {
+ "name": "role_parent",
+ "schema": "",
+ "columns": {
+ "role_id": {
+ "name": "role_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_role_id": {
+ "name": "parent_role_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "roleParent_parent_idx": {
+ "name": "roleParent_parent_idx",
+ "columns": [
+ {
+ "expression": "parent_role_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "role_parent_role_id_role_id_fk": {
+ "name": "role_parent_role_id_role_id_fk",
+ "tableFrom": "role_parent",
+ "tableTo": "role",
+ "columnsFrom": ["role_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "role_parent_parent_role_id_role_id_fk": {
+ "name": "role_parent_parent_role_id_role_id_fk",
+ "tableFrom": "role_parent",
+ "tableTo": "role",
+ "columnsFrom": ["parent_role_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "role_parent_role_id_parent_role_id_pk": {
+ "name": "role_parent_role_id_parent_role_id_pk",
+ "columns": ["role_id", "parent_role_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.role_permission": {
+ "name": "role_permission",
+ "schema": "",
+ "columns": {
+ "role_id": {
+ "name": "role_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission_id": {
+ "name": "permission_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "rolePermission_permission_idx": {
+ "name": "rolePermission_permission_idx",
+ "columns": [
+ {
+ "expression": "permission_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "role_permission_role_id_role_id_fk": {
+ "name": "role_permission_role_id_role_id_fk",
+ "tableFrom": "role_permission",
+ "tableTo": "role",
+ "columnsFrom": ["role_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "role_permission_permission_id_permission_id_fk": {
+ "name": "role_permission_permission_id_permission_id_fk",
+ "tableFrom": "role_permission",
+ "tableTo": "permission",
+ "columnsFrom": ["permission_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "role_permission_role_id_permission_id_pk": {
+ "name": "role_permission_role_id_permission_id_pk",
+ "columns": ["role_id", "permission_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_role": {
+ "name": "user_role",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role_id": {
+ "name": "role_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "assigned_by": {
+ "name": "assigned_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "assignedAt": {
+ "name": "assignedAt",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "userRole_role_idx": {
+ "name": "userRole_role_idx",
+ "columns": [
+ {
+ "expression": "role_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_role_user_id_user_id_fk": {
+ "name": "user_role_user_id_user_id_fk",
+ "tableFrom": "user_role",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_role_role_id_role_id_fk": {
+ "name": "user_role_role_id_role_id_fk",
+ "tableFrom": "user_role",
+ "tableTo": "role",
+ "columnsFrom": ["role_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_role_user_id_role_id_pk": {
+ "name": "user_role_user_id_role_id_pk",
+ "columns": ["user_id", "role_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.access_outbox": {
+ "name": "access_outbox",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigint",
+ "primaryKey": true,
+ "notNull": true,
+ "identity": {
+ "type": "always",
+ "name": "access_outbox_id_seq",
+ "schema": "public",
+ "increment": "1",
+ "startWith": "1",
+ "minValue": "1",
+ "maxValue": "9223372036854775807",
+ "cache": "1",
+ "cycle": false
+ }
+ },
+ "projection": {
+ "name": "projection",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "next_attempt_at": {
+ "name": "next_attempt_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.access_snapshot_state": {
+ "name": "access_snapshot_state",
+ "schema": "",
+ "columns": {
+ "projection": {
+ "name": "projection",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "generation": {
+ "name": "generation",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.entra_group_observation": {
+ "name": "entra_group_observation",
+ "schema": "",
+ "columns": {
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "members": {
+ "name": "members",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "observed_at": {
+ "name": "observed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index d0b6d23..364048d 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -155,6 +155,13 @@
"when": 1792061144133,
"tag": "0021_passkey_prompt_declined",
"breakpoints": true
+ },
+ {
+ "idx": 22,
+ "version": "7",
+ "when": 1792827000000,
+ "tag": "0022_idp_mcp",
+ "breakpoints": true
}
]
}
diff --git a/package.json b/package.json
index fc05a1d..19d27a7 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"@faker-js/faker": "^10.3.0",
"@fontsource/manrope": "^5.3.0",
"@microsoft/microsoft-graph-client": "^3.0.7",
+ "@modelcontextprotocol/server": "^2.3.1",
"@sentry/tanstackstart-react": "^10.67.0",
"@t3-oss/env-core": "^0.13.10",
"@tailwindcss/vite": "^4.1.18",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 513a4cb..7ec7e77 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -139,6 +139,9 @@ importers:
'@microsoft/microsoft-graph-client':
specifier: ^3.0.7
version: 3.0.7(@azure/identity@4.13.2)(@azure/msal-browser@5.21.0)
+ '@modelcontextprotocol/server':
+ specifier: ^2.3.1
+ version: 2.3.1
'@sentry/tanstackstart-react':
specifier: ^10.67.0
version: 10.73.0(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(react@19.2.8)
@@ -1028,6 +1031,14 @@ packages:
stream-browserify:
optional: true
+ '@modelcontextprotocol/core@2.3.1':
+ resolution: {integrity: sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==}
+ engines: {node: '>=20'}
+
+ '@modelcontextprotocol/server@2.3.1':
+ resolution: {integrity: sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==}
+ engines: {node: '>=20'}
+
'@napi-rs/wasm-runtime@1.2.3':
resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
@@ -5462,6 +5473,15 @@ snapshots:
'@azure/identity': 4.13.2
'@azure/msal-browser': 5.21.0
+ '@modelcontextprotocol/core@2.3.1':
+ dependencies:
+ zod: 4.5.4
+
+ '@modelcontextprotocol/server@2.3.1':
+ dependencies:
+ '@modelcontextprotocol/core': 2.3.1
+ zod: 4.5.4
+
'@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)':
dependencies:
'@emnapi/core': 1.11.3
diff --git a/src/auth/account.functions.ts b/src/auth/account.functions.ts
index 563e7eb..fad24d9 100644
--- a/src/auth/account.functions.ts
+++ b/src/auth/account.functions.ts
@@ -10,6 +10,9 @@ import { ownSessionToken, readAccountOverview } from "./account-overview";
import { disconnectAccount } from "./accounts";
import { getIdentity } from "./identity";
import { auth } from "./index";
+import { MCP_SCOPE } from "./mcp-clients";
+import { disconnectMcpConnection, listMcpConnections } from "./mcp-connections";
+import { mcpResource } from "./mcp-resource";
import { actionMiddleware, sessionMiddleware } from "./middleware";
import { declinePasskeyPrompt } from "./passkey-prompt";
import { confirmStudentVerification, requestStudentVerification } from "./student-verification";
@@ -30,11 +33,10 @@ export const getAccount = createServerFn({ method: "GET" })
auth.api.listPasskeys({ headers }),
auth.api.getSession({ headers }),
]);
- const overview = await readAccountOverview(
- context.session.userId,
- current?.session.id ?? "",
- identity,
- );
+ const [overview, agents] = await Promise.all([
+ readAccountOverview(context.session.userId, current?.session.id ?? "", identity),
+ listMcpConnections(context.session.userId),
+ ]);
return {
accounts: accounts.map(({ id, providerId, accountId }) => ({ id, providerId, accountId })),
identity,
@@ -44,6 +46,11 @@ export const getAccount = createServerFn({ method: "GET" })
createdAt,
})),
...overview,
+ // Shown to whoever may use the MCP server, and to anyone with an agent left to disconnect.
+ mcp:
+ identity.permissions.includes(MCP_SCOPE) || agents.length
+ ? { serverUrl: mcpResource, agents }
+ : null,
};
});
@@ -69,6 +76,14 @@ export const revokeOtherSessionsFn = createServerFn({ method: "POST" })
await auth.api.revokeOtherSessions({ headers: getRequest().headers });
});
+/** Ends one agent's access to the MCP server; its tokens stop working on the next request. */
+export const disconnectMcpAgentFn = createServerFn({ method: "POST" })
+ .middleware([sessionMiddleware])
+ .validator(validate(z.object({ clientId: z.string().min(1).max(200) })))
+ .handler(async ({ context, data }) => {
+ await disconnectMcpConnection(context.session.userId, data.clientId);
+ });
+
/** Removes one of the person's linked accounts; the last login method is refused. */
export const unlinkAccountFn = createServerFn({ method: "POST" })
.middleware([sessionMiddleware])
diff --git a/src/auth/data-retention.ts b/src/auth/data-retention.ts
index 1c27f51..f844c71 100644
--- a/src/auth/data-retention.ts
+++ b/src/auth/data-retention.ts
@@ -7,14 +7,16 @@ import {
session,
verification,
} from "../db/schema";
+import { pruneMcpClients } from "./mcp-connections";
/** The privacy policy deletes expired sessions within this long after they expire. */
const EXPIRED_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Deletes what the privacy policy no longer lets us keep: sessions (with their IP address and
- * user agent), verification values and OAuth tokens 30 days after they expire, and RBAC audit
- * events once they are five years old. Better Auth refuses every expired row already, so none
+ * user agent), verification values and OAuth tokens 30 days after they expire, agent
+ * registrations nobody uses (`pruneMcpClients`), and RBAC audit events once they are five
+ * years old. Better Auth refuses every expired row already, so none
* of them can still be used. Migration `0020` lets the database delete audit events only past
* the same five years, measured with the same clock.
*/
@@ -24,6 +26,7 @@ export async function pruneExpiredData() {
await db.delete(verification).where(lt(verification.expiresAt, cutoff));
await db.delete(oauthAccessToken).where(lt(oauthAccessToken.expiresAt, cutoff));
await db.delete(oauthRefreshToken).where(lt(oauthRefreshToken.expiresAt, cutoff));
+ await pruneMcpClients();
await db
.delete(rbacAuditEvent)
.where(lt(rbacAuditEvent.createdAt, sql`now() - interval '5 years'`));
diff --git a/src/auth/identity.integration.test.ts b/src/auth/identity.integration.test.ts
index c7d4601..db304b1 100644
--- a/src/auth/identity.integration.test.ts
+++ b/src/auth/identity.integration.test.ts
@@ -1085,8 +1085,8 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration"
const pair = await generateKeyPair("EdDSA", { crv: "Ed25519", extractable: true });
const jwks = JSON.stringify({ keys: [{ ...(await exportJWK(pair.publicKey)), kid: "raced" }] });
await insertSecretClient("integration-key-holder");
- // The creation checks the key, then the provider inserts outside the RBAC lock. A locked
- // save gives the same key to another client before the creation's own recheck runs.
+ // The creation checks keys under the RBAC lock. A locked save gives the same key to
+ // another client while the creation waits for it.
const holder = await holdRbacLock();
try {
const creation = callAs("application-writer-session", createOidcClientFn, {
@@ -1112,51 +1112,33 @@ describe.skipIf(!baseURL || !databaseURL || !secret)("identity HTTP integration"
);
}
});
- it("keeps a new client whose key was replaced under the lock before its recheck", async () => {
+ it("registers nothing until it holds the RBAC lock", async () => {
const { createOidcClientFn } = await import("./oidc.functions");
- const publicJwks = async (kid: string) => {
- const pair = await generateKeyPair("EdDSA", { crv: "Ed25519", extractable: true });
- return JSON.stringify({ keys: [{ ...(await exportJWK(pair.publicKey)), kid }] });
- };
- const original = await publicJwks("original");
- const replacement = await publicJwks("replacement");
- await insertSecretClient("integration-key-taker");
- // While the creation waits to recheck, a locked save gives the new client another key
- // and API access, and the original key goes to a different client. The recheck must look
- // at the key the new client holds now, and must not delete it.
+ const pair = await generateKeyPair("EdDSA", { crv: "Ed25519", extractable: true });
+ const jwks = JSON.stringify({ keys: [{ ...(await exportJWK(pair.publicKey)), kid: "waits" }] });
+ // A promotion holding the lock must never find a half-created client to race with.
const holder = await holdRbacLock();
try {
const creation = callAs("application-writer-session", createOidcClientFn, {
- draft: clientDraft({ name: "Rekeyed app", credential: "private-key", jwks: original }),
+ draft: clientDraft({ name: "Waiting key app", credential: "private-key", jwks }),
});
await inProcessLockWaiter();
- await holder.query(
- `UPDATE oauth_client SET jwks = $1,
- grant_types = ARRAY['authorization_code', 'client_credentials']
- WHERE name = 'Rekeyed app'`,
- [JSON.stringify(JSON.parse(replacement))],
- );
- await holder.query(
- `UPDATE oauth_client SET token_endpoint_auth_method = 'private_key_jwt',
- client_secret = NULL, jwks = $1
- WHERE client_id = 'integration-key-taker'`,
- [JSON.stringify(JSON.parse(original))],
- );
+ expect(
+ (await pool.query(`SELECT id FROM oauth_client WHERE name = 'Waiting key app'`)).rows,
+ ).toHaveLength(0);
await holder.query("COMMIT");
expect((await creation).status).toBe(200);
expect(
(
await pool.query<{ jwks: string }>(
- `SELECT jwks FROM oauth_client WHERE name = 'Rekeyed app'`,
+ `SELECT jwks FROM oauth_client WHERE name = 'Waiting key app'`,
)
).rows,
- ).toEqual([{ jwks: JSON.stringify(JSON.parse(replacement)) }]);
+ ).toEqual([{ jwks: JSON.stringify(JSON.parse(jwks)) }]);
} finally {
await holder.query("ROLLBACK").catch(() => {});
holder.release();
- await pool.query(
- `DELETE FROM oauth_client WHERE client_id = 'integration-key-taker' OR name = 'Rekeyed app'`,
- );
+ await pool.query(`DELETE FROM oauth_client WHERE name = 'Waiting key app'`);
}
});
it("does not expose server-only resource administration over HTTP", async () => {
diff --git a/src/auth/index.ts b/src/auth/index.ts
index 3642135..0bff64f 100644
--- a/src/auth/index.ts
+++ b/src/auth/index.ts
@@ -1,6 +1,7 @@
import { getOAuthProviderState, oauthProvider } from "@better-auth/oauth-provider";
import { getAuthenticatorName, passkey } from "@better-auth/passkey";
import { eq } from "drizzle-orm";
+import { z } from "zod";
import { betterAuth, type User } from "better-auth";
import { APIError, createAuthMiddleware } from "better-auth/api";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
@@ -25,6 +26,8 @@ import {
import { ensureIdTokenKey, jwtOptions } from "./jwt-options";
import { startKeyPruning } from "./key-pruning";
import { logAuthorizationDenial } from "./denial-log";
+import { MCP_CLIENT_SCOPES, MCP_SCOPE, checkMcpRegistration, narrowMcpScope } from "./mcp-clients";
+import { finishMcpRegistration, mcpClientScopes, mcpResource } from "./mcp-resource";
import { hasIdpPermission } from "./idp-access";
import { canAdministerIdp } from "./oidc-admin";
import { validateClientJwks } from "./client-jwks";
@@ -40,6 +43,16 @@ import {
registrationOptionsSchema,
} from "./passkeys";
+const registeredClientSchema = z.object({ client_id: z.string().min(1) });
+
+/** Token lifetimes in seconds. The MCP server reports them to agents debugging a client. */
+export const tokenLifetimes = {
+ accessToken: 300,
+ clientCredentialsAccessToken: 3600,
+ idToken: 300,
+ refreshToken: 30 * 24 * 60 * 60,
+};
+
const backendScopes = [
"openid",
"profile",
@@ -50,6 +63,13 @@ const backendScopes = [
const serviceOAuthEnabled = Boolean(
env.OAUTH_BACKEND_RESOURCE_URI && env.OAUTH_INTERNAL_RESOURCE_URI,
);
+// Agents connected to the MCP server get short-lived tokens addressed to it alone.
+const mcpResourcePolicy = {
+ identifier: mcpResource,
+ accessTokenTtl: tokenLifetimes.accessToken,
+ allowedScopes: MCP_CLIENT_SCOPES,
+ signingAlgorithm: "EdDSA" as const,
+};
const serviceResources =
env.OAUTH_BACKEND_RESOURCE_URI && env.OAUTH_INTERNAL_RESOURCE_URI
? [
@@ -99,6 +119,41 @@ export const auth = betterAuth({
message: "Telegram can only be connected to an existing account.",
});
}
+ if (context.path === "/oauth2/register") {
+ // Anonymous registration exists for MCP agents only. Applications in the shared pool
+ // are registered from the application pages, which check them under the RBAC lock.
+ if (await auth.api.getSession({ headers: context.headers ?? new Headers() }))
+ throw new APIError("FORBIDDEN", {
+ error: "access_denied",
+ error_description: "Register applications from the application pages.",
+ });
+ const registration = checkMcpRegistration(context.body);
+ if (registration.problem)
+ throw new APIError("BAD_REQUEST", {
+ error: "invalid_client_metadata",
+ error_description: registration.problem,
+ });
+ return {
+ context: {
+ body: {
+ ...context.body,
+ application_type: registration.applicationType,
+ scope: registration.scope,
+ },
+ },
+ };
+ }
+ // An agent that asks for more than it may have gets what it may have, not an error.
+ if (
+ context.path === "/oauth2/authorize" &&
+ typeof context.query?.scope === "string" &&
+ typeof context.query.client_id === "string" &&
+ context.query.scope.split(" ").includes(MCP_SCOPE)
+ ) {
+ const allowed = await mcpClientScopes(context.query.client_id);
+ const scope = allowed && narrowMcpScope(context.query.scope, allowed);
+ if (scope) return { context: { query: { ...context.query, scope } } };
+ }
if (refusedClientMutationPaths.includes(context.path))
throw new APIError("FORBIDDEN", {
code: "CLIENT_MUTATION_UNSUPPORTED",
@@ -142,6 +197,11 @@ export const auth = betterAuth({
}
}),
after: createAuthMiddleware(async (context) => {
+ if (context.path === "/oauth2/register") {
+ const registered = registeredClientSchema.safeParse(context.context.returned);
+ if (registered.success) await finishMcpRegistration(registered.data.client_id);
+ return;
+ }
if (context.path === "/passkey/generate-register-options" && context.context.session) {
const options = registrationOptionsSchema.safeParse(context.context.returned);
if (!options.success) return;
@@ -193,16 +253,21 @@ export const auth = betterAuth({
loginPage: "/",
consentPage: "/consent",
scopes: serviceOAuthEnabled
- ? ["polinetwork:identity", ...backendScopes, "idp:access:read"]
- : ["openid", "profile", "email", "polinetwork:identity", "offline_access"],
+ ? ["polinetwork:identity", ...backendScopes, "idp:access:read", MCP_SCOPE]
+ : ["openid", "profile", "email", "polinetwork:identity", "offline_access", MCP_SCOPE],
grantTypes: serviceOAuthEnabled
? ["authorization_code", "refresh_token", "client_credentials"]
: ["authorization_code", "refresh_token"],
- resources: serviceResources,
+ resources: [mcpResourcePolicy, ...serviceResources],
resourceSeedMode: "overwrite",
rateLimit: oauthRateLimit,
enforcePerClientResources: true,
- allowDynamicClientRegistration: false,
+ // Open to MCP agents only: the `/oauth2/register` hooks refuse anything else, these
+ // scopes are all such a client may ever ask for, and it is linked to the MCP server
+ // alone, so its tokens are useless anywhere else.
+ allowDynamicClientRegistration: true,
+ allowUnauthenticatedClientRegistration: true,
+ clientRegistrationDefaultScopes: MCP_CLIENT_SCOPES,
// Lets the login page name the requesting app before the user signs in.
allowPublicClientPrelogin: true,
// Offers a passkey once, on the way to the app; the page continues the sign-in after.
@@ -242,10 +307,11 @@ export const auth = betterAuth({
serviceOAuthEnabled && user && ["read", "list"].includes(action)
? canAdministerIdp(user.id, db, true)
: false,
- accessTokenExpiresIn: 300,
- m2mAccessTokenExpiresIn: 3600,
+ accessTokenExpiresIn: tokenLifetimes.accessToken,
+ m2mAccessTokenExpiresIn: tokenLifetimes.clientCredentialsAccessToken,
+ refreshTokenExpiresIn: tokenLifetimes.refreshToken,
refreshTokenReuseInterval: 10,
- idTokenExpiresIn: 300,
+ idTokenExpiresIn: tokenLifetimes.idToken,
customIdTokenClaims: async ({ user, scopes }) => ({
...standardIdTokenClaims(await withAppEmail(user, scopes), scopes),
...(await getOidcClaims(user.id, scopes)),
diff --git a/src/auth/mcp-clients.test.ts b/src/auth/mcp-clients.test.ts
new file mode 100644
index 0000000..70d3a4a
--- /dev/null
+++ b/src/auth/mcp-clients.test.ts
@@ -0,0 +1,135 @@
+import { describe, expect, it } from "vite-plus/test";
+import {
+ MCP_CLIENT_SCOPES,
+ checkMcpRegistration,
+ isLoopbackRedirect,
+ mcpSetupCommands,
+ narrowMcpScope,
+} from "./mcp-clients";
+import { describeScope } from "./oidc-clients";
+
+// What Claude Code and Codex actually send when they register themselves.
+const claudeCode = {
+ client_name: "Claude Code (polinetwork-auth)",
+ redirect_uris: ["http://localhost:51234/callback"],
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ token_endpoint_auth_method: "none",
+};
+const codex = {
+ client_name: "Codex",
+ redirect_uris: ["http://127.0.0.1:41234/callback"],
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ token_endpoint_auth_method: "none",
+ scope: "openid profile email polinetwork:identity offline_access idp:mcp",
+ application_type: "native",
+};
+
+describe("MCP agent registration", () => {
+ it("accepts the agents' own registrations", () => {
+ expect(checkMcpRegistration(claudeCode)).toEqual({
+ problem: null,
+ // Better Auth would read the missing type as web, which refuses loopback redirects.
+ applicationType: "native",
+ scope: MCP_CLIENT_SCOPES.join(" "),
+ });
+ expect(checkMcpRegistration(codex)).toEqual({
+ problem: null,
+ applicationType: "native",
+ // Agents ask for every scope the server lists; they get the ones they may have.
+ scope: "openid offline_access idp:mcp",
+ });
+ });
+
+ it("accepts hosted agents' exact callbacks as web clients", () => {
+ const hosted = checkMcpRegistration({
+ redirect_uris: ["https://claude.ai/api/mcp/auth_callback"],
+ token_endpoint_auth_method: "client_secret_post",
+ });
+ expect(hosted).toMatchObject({ problem: null, applicationType: "web" });
+ const vscode = checkMcpRegistration({
+ redirect_uris: ["https://vscode.dev/redirect", "http://127.0.0.1:33418/"],
+ token_endpoint_auth_method: "none",
+ });
+ expect(vscode).toMatchObject({ problem: null, applicationType: "native" });
+ });
+
+ it("refuses redirects that would send the code to someone else's machine", () => {
+ for (const uri of [
+ "https://evil.example/callback",
+ "https://claude.ai/api/mcp/auth_callback/../../evil",
+ "https://claude.ai.evil.example/api/mcp/auth_callback",
+ "http://192.168.1.10/callback",
+ "http://localhost.evil.example/callback",
+ "https://localhost/callback",
+ "cursor://anysphere.cursor-mcp/oauth/callback",
+ ])
+ expect(checkMcpRegistration({ ...claudeCode, redirect_uris: [uri] }).problem).toMatch(
+ /may only redirect/,
+ );
+ expect(
+ checkMcpRegistration({
+ ...claudeCode,
+ redirect_uris: ["http://localhost:1/cb", "https://evil.example/cb"],
+ }).problem,
+ ).toMatch(/may only redirect/);
+ });
+
+ it("refuses everything an agent does not need", () => {
+ expect(checkMcpRegistration({ ...claudeCode, redirect_uris: [] }).problem).toBeTruthy();
+ expect(checkMcpRegistration({ token_endpoint_auth_method: "none" }).problem).toBeTruthy();
+ expect(
+ checkMcpRegistration({ ...claudeCode, grant_types: ["client_credentials"] }).problem,
+ ).toMatch(/authorization code/);
+ expect(checkMcpRegistration({ ...claudeCode, grant_types: ["refresh_token"] }).problem).toMatch(
+ /authorization code/,
+ );
+ expect(
+ checkMcpRegistration({ ...claudeCode, token_endpoint_auth_method: "private_key_jwt" })
+ .problem,
+ ).toMatch(/secret or as public/);
+ expect(
+ checkMcpRegistration({ ...claudeCode, jwks_uri: "https://evil.example/jwks" }).problem,
+ ).toMatch(/keys/);
+ expect(checkMcpRegistration({ ...claudeCode, application_type: "web" }).problem).toMatch(
+ /native/,
+ );
+ });
+});
+
+describe("MCP scopes", () => {
+ it("keeps only what an agent may have", () => {
+ expect(narrowMcpScope("openid profile idp:mcp offline_access", MCP_CLIENT_SCOPES)).toBe(
+ "openid idp:mcp offline_access",
+ );
+ expect(narrowMcpScope("idp:mcp idp:mcp", MCP_CLIENT_SCOPES)).toBe("idp:mcp");
+ expect(narrowMcpScope("profile email", MCP_CLIENT_SCOPES)).toBeNull();
+ expect(narrowMcpScope(undefined, MCP_CLIENT_SCOPES)).toBeNull();
+ });
+
+ it("explains the MCP scope on the consent page", () => {
+ expect(describeScope("idp:mcp").label).not.toBe("idp:mcp");
+ });
+});
+
+describe("loopback redirects", () => {
+ it("matches the exact loopback hosts over http on any port", () => {
+ expect(isLoopbackRedirect("http://localhost:3000/callback")).toBe(true);
+ expect(isLoopbackRedirect("http://127.0.0.1/callback")).toBe(true);
+ expect(isLoopbackRedirect("http://[::1]:8080/callback")).toBe(true);
+ expect(isLoopbackRedirect("http://127.0.0.2/callback")).toBe(false);
+ expect(isLoopbackRedirect("http://user:pw@localhost/callback")).toBe(false);
+ expect(isLoopbackRedirect("http://localhost/callback#x")).toBe(false);
+ expect(isLoopbackRedirect("not a url")).toBe(false);
+ });
+});
+
+describe("setup commands", () => {
+ it("names the server URL in every command", () => {
+ const url = "https://auth.polinetwork.org/api/mcp";
+ const commands = mcpSetupCommands(url);
+ expect(commands.map((entry) => entry.id)).toEqual(["claude", "codex", "gemini", "vscode"]);
+ for (const entry of commands) expect(entry.command).toContain(url);
+ });
+});
diff --git a/src/auth/mcp-clients.ts b/src/auth/mcp-clients.ts
new file mode 100644
index 0000000..9cd651f
--- /dev/null
+++ b/src/auth/mcp-clients.ts
@@ -0,0 +1,135 @@
+// Shared by the server and the browser: keep this file free of server-only imports.
+
+/** Where the MCP server answers, on the public origin. */
+export const MCP_PATH = "/api/mcp";
+
+/** The scope every MCP access token carries, named after the permission that gates it. */
+export const MCP_SCOPE = "idp:mcp";
+
+/**
+ * Everything a self-registered MCP client may ask for. `openid` is there for clients that
+ * add it out of habit and only reveals the user ID; `offline_access` lets them refresh.
+ * Profile, email and identity claims are never offered to these clients.
+ */
+export const MCP_CLIENT_SCOPES = ["openid", "offline_access", MCP_SCOPE];
+
+/**
+ * Callback pages of hosted agents, matched exactly. A self-registered client may otherwise
+ * only redirect to the person's own machine, where an attacker who tricks someone into
+ * approving a client cannot collect the code.
+ */
+export const HOSTED_MCP_CALLBACKS = [
+ "https://claude.ai/api/mcp/auth_callback",
+ "https://claude.com/api/mcp/auth_callback",
+ "https://chatgpt.com/connector_platform_oauth_redirect",
+ "https://vscode.dev/redirect",
+ "https://insiders.vscode.dev/redirect",
+];
+
+/**
+ * The part of a requested scope string an MCP client may have, or null when that is nothing.
+ * Agents often ask for every scope the authorization server lists; the server may grant
+ * fewer than asked (RFC 6749 §3.3), and the token response tells the agent what it got.
+ */
+export function narrowMcpScope(requested: unknown, allowed: readonly string[]) {
+ if (typeof requested !== "string") return null;
+ const kept = [...new Set(requested.split(" ").filter((scope) => allowed.includes(scope)))];
+ return kept.length ? kept.join(" ") : null;
+}
+
+const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
+
+/** `http://localhost`, `http://127.0.0.1` or `http://[::1]`, on any port and path. */
+export function isLoopbackRedirect(uri: string) {
+ try {
+ const url = new URL(uri);
+ return (
+ url.protocol === "http:" &&
+ LOOPBACK_HOSTS.has(url.hostname) &&
+ !url.username &&
+ !url.password &&
+ !uri.includes("#")
+ );
+ } catch {
+ return false;
+ }
+}
+
+const AUTH_METHODS = ["none", "client_secret_basic", "client_secret_post"];
+const GRANT_TYPES = ["authorization_code", "refresh_token"];
+
+export type McpRegistration = {
+ /** Why the registration is refused, or null when it may go ahead. */
+ problem: string | null;
+ /** The application type to register with, when the client left it out. */
+ applicationType: "web" | "native";
+ /** The requested scopes it may have; every MCP client gets `MCP_CLIENT_SCOPES` anyway. */
+ scope: string;
+};
+
+/**
+ * Whether an anonymous dynamic client registration is one an MCP agent would make: it signs
+ * people in with the authorization code flow, redirects only to the person's machine or a
+ * hosted agent, and carries no keys. Better Auth checks the rest of the metadata afterwards,
+ * and limits the scopes and resources to the MCP server's.
+ */
+export function checkMcpRegistration(body: Record | undefined): McpRegistration {
+ const scope = narrowMcpScope(body?.scope, MCP_CLIENT_SCOPES) ?? MCP_CLIENT_SCOPES.join(" ");
+ const refuse = (problem: string): McpRegistration => ({
+ problem,
+ applicationType: "native",
+ scope,
+ });
+ const redirects = body?.redirect_uris;
+ if (!Array.isArray(redirects) || redirects.length === 0 || redirects.length > 10)
+ return refuse("Register between one and ten redirect URIs.");
+ if (redirects.some((uri) => typeof uri !== "string"))
+ return refuse("Redirect URIs must be strings.");
+ const loopback = redirects.filter((uri: string) => isLoopbackRedirect(uri));
+ const hosted = redirects.filter((uri: string) => HOSTED_MCP_CALLBACKS.includes(uri));
+ if (loopback.length + hosted.length !== redirects.length)
+ return refuse(
+ "MCP clients may only redirect to http://localhost, http://127.0.0.1, http://[::1], or a known hosted agent.",
+ );
+ const grants = body?.grant_types ?? ["authorization_code"];
+ if (
+ !Array.isArray(grants) ||
+ !grants.includes("authorization_code") ||
+ grants.some((grant) => typeof grant !== "string" || !GRANT_TYPES.includes(grant.trim()))
+ )
+ return refuse("MCP clients use the authorization code grant, with refresh tokens if needed.");
+ const method = body?.token_endpoint_auth_method ?? "client_secret_basic";
+ if (typeof method !== "string" || !AUTH_METHODS.includes(method.trim()))
+ return refuse("MCP clients authenticate with a client secret or as public clients.");
+ if (body?.jwks !== undefined || body?.jwks_uri !== undefined)
+ return refuse("MCP clients cannot register keys.");
+ // Better Auth would read a missing type as "web", which refuses loopback redirects.
+ const declared = body?.application_type;
+ const applicationType =
+ declared === "web" || declared === "native" ? declared : loopback.length ? "native" : "web";
+ if (applicationType === "web" && loopback.length)
+ return refuse("Loopback redirect URIs need the native application type.");
+ return { problem: null, applicationType, scope };
+}
+
+/** The name agents list the server under. */
+export const MCP_SERVER_NAME = "polinetwork-auth";
+
+/** Ready-to-paste commands that add the server at `serverUrl` to the common agents. */
+export function mcpSetupCommands(serverUrl: string) {
+ const vscode = JSON.stringify({ name: MCP_SERVER_NAME, type: "http", url: serverUrl });
+ return [
+ {
+ id: "claude",
+ label: "Claude Code",
+ command: `claude mcp add --scope user --transport http ${MCP_SERVER_NAME} ${serverUrl}`,
+ },
+ { id: "codex", label: "Codex", command: `codex mcp add ${MCP_SERVER_NAME} --url ${serverUrl}` },
+ {
+ id: "gemini",
+ label: "Gemini CLI",
+ command: `gemini mcp add --scope user --transport http ${MCP_SERVER_NAME} ${serverUrl}`,
+ },
+ { id: "vscode", label: "VS Code", command: `code --add-mcp '${vscode}'` },
+ ];
+}
diff --git a/src/auth/mcp-connections.ts b/src/auth/mcp-connections.ts
new file mode 100644
index 0000000..82864ed
--- /dev/null
+++ b/src/auth/mcp-connections.ts
@@ -0,0 +1,104 @@
+import { and, count, desc, eq, lt, notExists, sql } from "drizzle-orm";
+import { db } from "../db/index";
+import { oauthAccessToken, oauthClient, oauthConsent, oauthRefreshToken } from "../db/schema";
+import { isMcpClient } from "./mcp-resource";
+
+export type McpConnection = {
+ clientId: string;
+ name: string | null;
+ /** Where the agent receives its sign-in, such as `127.0.0.1:53682` or `claude.ai`. */
+ redirectHost: string | null;
+ connectedAt: Date;
+ /** When the agent last refreshed its access, which it does every few minutes in use. */
+ lastUsedAt: Date | null;
+};
+
+function hostOf(uri: string | undefined) {
+ try {
+ return uri ? new URL(uri).host : null;
+ } catch {
+ return null;
+ }
+}
+
+/** The agents someone has connected to the MCP server, newest first. */
+export async function listMcpConnections(userId: string): Promise {
+ const rows = await db
+ .select({
+ clientId: oauthClient.clientId,
+ name: oauthClient.name,
+ redirectUris: oauthClient.redirectUris,
+ connectedAt: oauthConsent.createdAt,
+ lastUsedAt: sql`(select max(${oauthRefreshToken.createdAt}) from ${oauthRefreshToken}
+ where ${oauthRefreshToken.clientId} = ${oauthClient.clientId}
+ and ${oauthRefreshToken.userId} = ${userId})`.mapWith(oauthRefreshToken.createdAt),
+ })
+ .from(oauthConsent)
+ .innerJoin(oauthClient, eq(oauthClient.clientId, oauthConsent.clientId))
+ .where(and(eq(oauthConsent.userId, userId), isMcpClient))
+ .orderBy(desc(oauthConsent.createdAt));
+ return rows.map(({ redirectUris, ...row }) => ({
+ ...row,
+ redirectHost: hostOf(redirectUris[0]),
+ }));
+}
+
+/**
+ * Ends one agent's access for this person: their consent and tokens go, which the MCP server
+ * notices on the next request. An agent's client is its own registration, so it is deleted
+ * once nobody uses it. Returns false when the agent is not one of theirs.
+ */
+export async function disconnectMcpConnection(userId: string, clientId: string) {
+ return db.transaction(async (transaction) => {
+ await transaction.execute(sql`select set_config('polinetwork.actor_id', ${userId}, true)`);
+ const removed = await transaction
+ .delete(oauthConsent)
+ .where(
+ and(
+ eq(oauthConsent.userId, userId),
+ eq(oauthConsent.clientId, clientId),
+ sql`exists (select 1 from ${oauthClient} where ${oauthClient.clientId} = ${clientId} and ${isMcpClient})`,
+ ),
+ )
+ .returning({ id: oauthConsent.id });
+ if (!removed.length) return false;
+ await transaction
+ .delete(oauthRefreshToken)
+ .where(and(eq(oauthRefreshToken.userId, userId), eq(oauthRefreshToken.clientId, clientId)));
+ await transaction
+ .delete(oauthAccessToken)
+ .where(and(eq(oauthAccessToken.userId, userId), eq(oauthAccessToken.clientId, clientId)));
+ const [{ remaining }] = await transaction
+ .select({ remaining: count() })
+ .from(oauthConsent)
+ .where(eq(oauthConsent.clientId, clientId));
+ if (!remaining)
+ await transaction
+ .delete(oauthClient)
+ .where(and(eq(oauthClient.clientId, clientId), isMcpClient));
+ return true;
+ });
+}
+
+/** Agents get a day to finish signing in before an unused registration is removed. */
+const UNUSED_REGISTRATION_MS = 24 * 60 * 60 * 1000;
+
+/**
+ * Deletes agent registrations that hold no refresh token: abandoned sign-ins after a day,
+ * and agents whose last refresh token expired and was pruned, which takes 60 days of disuse.
+ * Anyone can register an agent, so this keeps their number bounded.
+ */
+export async function pruneMcpClients() {
+ await db.delete(oauthClient).where(
+ and(
+ isMcpClient,
+ lt(oauthClient.createdAt, new Date(Date.now() - UNUSED_REGISTRATION_MS)),
+ notExists(
+ db
+ .select({ one: sql`1` })
+ .from(oauthRefreshToken)
+ .where(eq(oauthRefreshToken.clientId, oauthClient.clientId)),
+ ),
+ ),
+ );
+}
diff --git a/src/auth/mcp-diagnostics.test.ts b/src/auth/mcp-diagnostics.test.ts
new file mode 100644
index 0000000..2f7964a
--- /dev/null
+++ b/src/auth/mcp-diagnostics.test.ts
@@ -0,0 +1,214 @@
+import { describe, expect, it } from "vite-plus/test";
+import {
+ checkAuthorizationRequest,
+ diagnoseClient,
+ explainPermission,
+ matchRedirectUri,
+} from "./mcp-diagnostics";
+import type { OidcClientSummary } from "./oidc-clients";
+import type { PermissionSummary, RbacCatalog, RoleSummary } from "./rbac";
+
+const authorizeEndpoint = "https://auth.polinetwork.org/api/auth/oauth2/authorize";
+const resources = { backend: "https://backend.internal.polinetwork.org" };
+
+function client(overrides: Partial = {}): OidcClientSummary {
+ return {
+ clientId: "wiki",
+ name: "Wiki",
+ uri: null,
+ logo: null,
+ logoDark: null,
+ redirectUris: ["https://wiki.polinetwork.org/callback"],
+ postLogoutRedirectUris: [],
+ contacts: [],
+ tosUri: null,
+ policyUri: null,
+ scopes: ["openid", "profile", "offline_access"],
+ credential: "secret",
+ restricted: false,
+ authMethod: "client_secret_basic",
+ grantTypes: ["authorization_code", "refresh_token"],
+ clientCredentialsScopes: [],
+ resourceIds: [],
+ resources: [],
+ enableEndSession: false,
+ jwks: null,
+ applicationType: "web",
+ disabled: false,
+ skipConsent: false,
+ createdAt: null,
+ updatedAt: null,
+ authorizedUsers: 3,
+ ...overrides,
+ };
+}
+
+function authorize(params: Record) {
+ return `${authorizeEndpoint}?${new URLSearchParams({
+ response_type: "code",
+ client_id: "wiki",
+ redirect_uri: "https://wiki.polinetwork.org/callback",
+ scope: "openid profile",
+ state: "s",
+ code_challenge: "c",
+ code_challenge_method: "S256",
+ ...params,
+ })}`;
+}
+
+function messages(findings: { level: string; message: string }[], level = "error") {
+ return findings.filter((finding) => finding.level === level).map((finding) => finding.message);
+}
+
+describe("redirect URI matching", () => {
+ it("matches exactly, except for the port of a loopback IP address", () => {
+ const registered = ["https://app.example/cb", "http://127.0.0.1:3000/cb", "http://[::1]/cb"];
+ expect(matchRedirectUri(registered, "https://app.example/cb")).toBe(registered[0]);
+ expect(matchRedirectUri(registered, "https://app.example/cb/")).toBeUndefined();
+ expect(matchRedirectUri(registered, "http://127.0.0.1:51234/cb")).toBe(registered[1]);
+ expect(matchRedirectUri(registered, "http://[::1]:8080/cb")).toBe(registered[2]);
+ expect(matchRedirectUri(["http://localhost:3000/cb"], "http://localhost:4000/cb")).toBe(
+ undefined,
+ );
+ });
+});
+
+describe("authorization request check", () => {
+ const context = { resources, authorizeEndpoint };
+
+ it("accepts a request the provider would accept", () => {
+ const findings = checkAuthorizationRequest(client(), authorize({}), context);
+ expect(messages(findings)).toEqual([]);
+ expect(messages(findings, "info").at(-1)).toMatch(/would accept/);
+ });
+
+ it("names the near miss behind an unregistered redirect URI", () => {
+ const trailing = checkAuthorizationRequest(
+ client(),
+ authorize({ redirect_uri: "https://wiki.polinetwork.org/callback/" }),
+ context,
+ );
+ expect(messages(trailing)[0]).toMatch(/trailing slash/);
+ const port = checkAuthorizationRequest(
+ client({ redirectUris: ["http://localhost:3000/cb"], applicationType: "native" }),
+ authorize({ redirect_uri: "http://localhost:4000/cb" }),
+ context,
+ );
+ expect(messages(port)[0]).toMatch(/port differs.*not localhost/);
+ const host = checkAuthorizationRequest(
+ client({ redirectUris: ["http://localhost:3000/cb"], applicationType: "native" }),
+ authorize({ redirect_uri: "http://127.0.0.1:3000/cb" }),
+ context,
+ );
+ expect(messages(host)[0]).toMatch(/loopback host differs/);
+ });
+
+ it("reports scopes, PKCE, response type, client and resource problems", () => {
+ const findings = messages(
+ checkAuthorizationRequest(
+ client({ disabled: true }),
+ authorize({
+ client_id: "other",
+ response_type: "token",
+ scope: "openid email",
+ code_challenge_method: "plain",
+ resource: resources.backend,
+ }),
+ context,
+ ),
+ );
+ expect(findings.join("\n")).toMatch(/client_id is other/);
+ expect(findings.join("\n")).toMatch(/unsupported_response_type/);
+ expect(findings.join("\n")).toMatch(/client_disabled/);
+ expect(findings.join("\n")).toMatch(/invalid_scope.*email/);
+ expect(findings.join("\n")).toMatch(/only S256/);
+ expect(findings.join("\n")).toMatch(/not linked to resource/);
+ });
+
+ it("reads a bare query string, and a request without PKCE", () => {
+ const query = new URL(authorize({})).search;
+ const findings = checkAuthorizationRequest(
+ client(),
+ query.replace(/&code_challenge=[^&]*/, ""),
+ context,
+ );
+ expect(messages(findings)).toEqual([expect.stringMatching(/PKCE is missing/)]);
+ });
+});
+
+describe("client diagnosis", () => {
+ it("finds settings that make tokens impossible", () => {
+ const findings = diagnoseClient(
+ client({
+ disabled: true,
+ redirectUris: [],
+ scopes: ["openid", "backend:admin"],
+ grantTypes: ["authorization_code", "refresh_token"],
+ }),
+ { resources },
+ );
+ const errors = messages(findings).join("\n");
+ expect(errors).toMatch(/disabled/);
+ expect(errors).toMatch(/no redirect URI/);
+ expect(errors).toMatch(/not linked to https:\/\/backend/);
+ expect(messages(findings, "warning").join("\n")).toMatch(/never given a refresh token/);
+ expect(findings[0]!.level).toBe("error");
+ });
+
+ it("reports nothing worse than notes for a healthy client", () => {
+ expect(messages(diagnoseClient(client(), { resources }))).toEqual([]);
+ expect(messages(diagnoseClient(client(), { resources }), "warning")).toEqual([]);
+ });
+});
+
+function permission(key: string, implies: string[] = []): PermissionSummary {
+ const base = { id: `p-${key}`, key, name: key, description: null, managed: false };
+ return { ...base, implies, roleCount: 0, createdAt: null, updatedAt: null };
+}
+
+function role(key: string, permissions: string[] = [], parents: string[] = []): RoleSummary {
+ const base = { id: `r-${key}`, key, name: key, description: null, managed: false };
+ return {
+ ...base,
+ sourceState: null,
+ permissions,
+ parents,
+ memberCount: 0,
+ createdAt: null,
+ updatedAt: null,
+ };
+}
+
+describe("permission explanation", () => {
+ const catalog: RbacCatalog = {
+ roles: [
+ role("master-admin"),
+ role("moderator", ["tg:moderate"]),
+ role("lead", [], ["moderator"]),
+ role("reader", ["tg:audit:read"]),
+ ],
+ permissions: [
+ permission("tg:moderate", ["tg:trusted"]),
+ permission("tg:trusted"),
+ permission("tg:audit:read"),
+ ],
+ };
+
+ it("follows role inheritance and then permission implications", () => {
+ expect(explainPermission(catalog, ["lead", "reader"], "tg:trusted")).toEqual([
+ {
+ heldRole: "lead",
+ rolePath: ["lead", "moderator"],
+ permissionPath: ["tg:moderate", "tg:trusted"],
+ },
+ ]);
+ });
+
+ it("explains Master Admin by its wildcard and nothing else", () => {
+ expect(explainPermission(catalog, ["master-admin"], "tg:audit:read")).toEqual([
+ { heldRole: "master-admin", rolePath: ["master-admin"], permissionPath: ["tg:audit:read"] },
+ ]);
+ expect(explainPermission(catalog, ["reader"], "tg:moderate")).toEqual([]);
+ expect(explainPermission(catalog, ["master-admin"], "no:such")).toEqual([]);
+ });
+});
diff --git a/src/auth/mcp-diagnostics.ts b/src/auth/mcp-diagnostics.ts
new file mode 100644
index 0000000..9923319
--- /dev/null
+++ b/src/auth/mcp-diagnostics.ts
@@ -0,0 +1,365 @@
+import { MASTER_ADMIN_ROLE_KEY, type RbacCatalog } from "./rbac";
+import {
+ isApiScope,
+ parsePublicJwks,
+ resourcesForScopes,
+ validateRedirectUri,
+ type OidcClientSummary,
+ type OidcResourceKey,
+} from "./oidc-clients";
+
+/**
+ * What the MCP server's debugging tools report. Each rule mirrors a check the OAuth provider
+ * makes, so a finding names the error the client actually gets back.
+ */
+export type Finding = { level: "error" | "warning" | "info"; message: string };
+
+export type DiagnosisContext = {
+ /** The resource identifiers this deployment configures, by key. */
+ resources: Partial>;
+};
+
+function findings() {
+ const list: Finding[] = [];
+ return {
+ list,
+ error: (message: string) => list.push({ level: "error", message }),
+ warning: (message: string) => list.push({ level: "warning", message }),
+ info: (message: string) => list.push({ level: "info", message }),
+ };
+}
+
+function isLoopbackIp(hostname: string) {
+ const host = hostname.replace(/^\[|\]$/g, "");
+ return host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host);
+}
+
+/**
+ * The registered redirect URI a request's `redirect_uri` matches, as the provider decides it:
+ * the exact string, or the same loopback IP address, scheme, path and query on any port
+ * (RFC 8252 §7.3). `localhost` gets no such leniency.
+ */
+export function matchRedirectUri(registered: readonly string[], requested: string) {
+ let request: URL | null = null;
+ try {
+ request = new URL(requested);
+ } catch {
+ request = null;
+ }
+ return registered.find((uri) => {
+ if (uri === requested) return true;
+ if (!request) return false;
+ try {
+ const entry = new URL(uri);
+ return (
+ isLoopbackIp(entry.hostname) &&
+ entry.hostname === request.hostname &&
+ entry.pathname === request.pathname &&
+ entry.protocol === request.protocol &&
+ entry.search === request.search
+ );
+ } catch {
+ return false;
+ }
+ });
+}
+
+/** Why a near miss did not match, for the commonest mistakes. */
+function nearMiss(registered: readonly string[], requested: string): string | null {
+ let request: URL;
+ try {
+ request = new URL(requested);
+ } catch {
+ return "It is not an absolute URL.";
+ }
+ const trim = (value: string) => value.replace(/\/+$/, "");
+ for (const uri of registered) {
+ let entry: URL;
+ try {
+ entry = new URL(uri);
+ } catch {
+ continue;
+ }
+ if (trim(uri) === trim(requested)) return `Only a trailing slash differs from ${uri}.`;
+ if (entry.host === request.host && entry.pathname === request.pathname) {
+ if (entry.protocol !== request.protocol) return `Only the scheme differs from ${uri}.`;
+ if (entry.search !== request.search) return `Only the query string differs from ${uri}.`;
+ }
+ if (entry.hostname === request.hostname && entry.pathname === request.pathname)
+ return `Only the port differs from ${uri}. Ports may vary only on 127.0.0.1 or [::1], not localhost.`;
+ if (
+ entry.pathname === request.pathname &&
+ entry.port === request.port &&
+ new Set([entry.hostname, request.hostname]).size === 2 &&
+ [entry.hostname, request.hostname].every((host) => host === "localhost" || isLoopbackIp(host))
+ )
+ return `Only the loopback host differs from ${uri}: localhost, 127.0.0.1 and [::1] are different URIs.`;
+ }
+ return null;
+}
+
+/** Problems with a pooled client's settings, worst first. */
+export function diagnoseClient(client: OidcClientSummary, context: DiagnosisContext): Finding[] {
+ const { list, error, warning, info } = findings();
+ const signIn = client.grantTypes.includes("authorization_code");
+ const service = client.grantTypes.includes("client_credentials");
+
+ if (client.disabled)
+ error("The application is disabled: authorization requests fail with `client_disabled`.");
+ if (!signIn && !service)
+ error(
+ "It has neither the authorization_code nor the client_credentials grant, so it can get no token.",
+ );
+
+ if (signIn) {
+ if (!client.redirectUris.length)
+ error(
+ "It signs people in but has no redirect URI, so every sign-in fails with `invalid_redirect`.",
+ );
+ for (const uri of client.redirectUris) {
+ const problem = validateRedirectUri(uri, client.applicationType);
+ if (problem) error(`Redirect URI ${uri}: ${problem}`);
+ }
+ for (const uri of client.postLogoutRedirectUris) {
+ const problem = validateRedirectUri(uri, client.applicationType);
+ if (problem) warning(`Post-logout redirect URI ${uri}: ${problem}`);
+ }
+ if (!client.scopes.includes("openid"))
+ warning("openid is not among its scopes, so it gets no ID token and cannot call UserInfo.");
+ const offline = client.scopes.includes("offline_access");
+ const refresh = client.grantTypes.includes("refresh_token");
+ if (offline && !refresh)
+ warning(
+ "It may ask for offline_access but lacks the refresh_token grant, so refreshing fails with `unauthorized_client`.",
+ );
+ if (refresh && !offline)
+ warning(
+ "It has the refresh_token grant but may not ask for offline_access, so it is never given a refresh token.",
+ );
+ if (client.postLogoutRedirectUris.length && !client.enableEndSession)
+ warning(
+ "Post-logout redirect URIs are registered but sign-out is off: `/oauth2/end-session` answers `invalid_client`.",
+ );
+ if (client.enableEndSession && !client.postLogoutRedirectUris.length)
+ info(
+ "Sign-out through `/oauth2/end-session` works, but it cannot send people back: no post-logout redirect URI is registered.",
+ );
+ }
+
+ if (service && !client.clientCredentialsScopes.length)
+ error(
+ "It uses client credentials without any scope for them, so token requests fail with `invalid_scope`.",
+ );
+
+ if (client.credential === "public")
+ info(
+ "Public client: no secret, PKCE with S256 on every sign-in, and no client credentials grant.",
+ );
+ else if (client.credential === "secret")
+ info(
+ "Confidential client using client_secret_basic: send the client ID and secret as HTTP Basic credentials to the token endpoint.",
+ );
+ else {
+ try {
+ const { keys } = parsePublicJwks(client.jwks ?? "");
+ const described = keys.map((key) =>
+ [key.kid, key.kty, key.crv]
+ .map((field) => (typeof field === "string" ? field : "?"))
+ .join(" "),
+ );
+ info(`Authenticates with private_key_jwt. Registered public keys: ${described.join(", ")}.`);
+ if (keys.length > 1 && keys.some((key) => key.kid === undefined))
+ warning(
+ "Several keys are registered and some have no kid, so a client assertion's key may be ambiguous.",
+ );
+ } catch (cause) {
+ error(`Its public JWKS is unusable: ${(cause as Error).message}`);
+ }
+ }
+ if (signIn) info("PKCE with S256 is required on every authorization request.");
+
+ const granted = [...client.scopes.filter(isApiScope), ...client.clientCredentialsScopes];
+ for (const key of resourcesForScopes(granted)) {
+ const identifier = context.resources[key];
+ if (!identifier)
+ error(`It holds ${key} API scopes, but this deployment configures no ${key} resource.`);
+ else if (!client.resourceIds.includes(identifier))
+ error(
+ `It holds ${key} API scopes but is not linked to ${identifier}, so requests naming that resource fail with \`invalid_target\`. Saving it as Master Admin links it.`,
+ );
+ }
+ const configured = Object.values(context.resources);
+ for (const identifier of client.resourceIds)
+ if (!configured.includes(identifier))
+ warning(`It is linked to ${identifier}, which this deployment no longer configures.`);
+
+ if (client.skipConsent) info("Consent is skipped: people are never asked to approve it.");
+ if (client.restricted) info("It has API access, so only Master Admin may change it.");
+ if (!client.authorizedUsers) info("Nobody has authorized it yet.");
+
+ const order = { error: 0, warning: 1, info: 2 };
+ return list.sort((a, b) => order[a.level] - order[b.level]);
+}
+
+/**
+ * Checks an authorization request the way `/oauth2/authorize` would, in the same order, and
+ * says what the client would get back. `request` is a full authorization URL or its query.
+ */
+export function checkAuthorizationRequest(
+ client: OidcClientSummary,
+ request: string,
+ context: DiagnosisContext & { authorizeEndpoint: string },
+): Finding[] {
+ const { list, error, warning, info } = findings();
+ let params: URLSearchParams;
+ try {
+ const url = new URL(request);
+ params = url.searchParams;
+ const expected = new URL(context.authorizeEndpoint);
+ if (url.origin !== expected.origin || url.pathname !== expected.pathname)
+ error(
+ `It goes to ${url.origin}${url.pathname}, not the authorization endpoint ${context.authorizeEndpoint}.`,
+ );
+ } catch {
+ params = new URLSearchParams(request.replace(/^\?/, ""));
+ }
+ const value = (name: string) => params.get(name) ?? undefined;
+
+ const clientId = value("client_id");
+ if (!clientId) error("client_id is missing.");
+ else if (clientId !== client.clientId)
+ error(`client_id is ${clientId}, not this application's ${client.clientId}.`);
+ const responseType = value("response_type");
+ if (!responseType) error("response_type is missing: send response_type=code.");
+ else if (responseType !== "code")
+ error(
+ `response_type=${responseType} is unsupported (\`unsupported_response_type\`): only code is.`,
+ );
+ if (client.disabled) error("The application is disabled (`client_disabled`).");
+ if (!client.grantTypes.includes("authorization_code"))
+ error("It may not use the authorization_code grant (`unauthorized_client`).");
+
+ const redirectUri = value("redirect_uri");
+ if (!redirectUri)
+ error("redirect_uri is missing (`invalid_redirect`). It is required on every request.");
+ else if (!matchRedirectUri(client.redirectUris, redirectUri)) {
+ const hint = nearMiss(client.redirectUris, redirectUri);
+ error(
+ `redirect_uri ${redirectUri} is not registered (\`invalid_redirect\`).${hint ? ` ${hint}` : ""}`,
+ );
+ }
+
+ const scope = value("scope");
+ const scopes = scope ? scope.split(" ").filter(Boolean) : [...client.scopes];
+ if (!scope)
+ info(`No scope was sent, so the application's scopes apply: ${client.scopes.join(" ")}.`);
+ const unknown = scopes.filter((entry) => !client.scopes.includes(entry));
+ if (unknown.length)
+ error(`These scopes are not allowed for it (\`invalid_scope\`): ${unknown.join(", ")}.`);
+ if (value("claims") !== undefined && !scopes.includes("openid"))
+ error("The claims parameter needs the openid scope (`invalid_request`).");
+
+ const resources = params.getAll("resource");
+ const configured = Object.values(context.resources);
+ for (const resource of resources) {
+ if (!configured.includes(resource))
+ error(`resource ${resource} is not configured here (\`invalid_target\`).`);
+ else if (!client.resourceIds.includes(resource))
+ error(`The application is not linked to resource ${resource} (\`invalid_target\`).`);
+ }
+ if (!resources.length && scopes.some(isApiScope))
+ warning(
+ "It asks for API scopes without a resource parameter, so the access token is opaque and addressed to no API.",
+ );
+
+ const challenge = value("code_challenge");
+ const method = value("code_challenge_method");
+ if (!challenge || !method)
+ error(
+ "PKCE is missing: send code_challenge with code_challenge_method=S256 (`invalid_request`).",
+ );
+ else if (method !== "S256")
+ error(`code_challenge_method=${method} is unsupported: only S256 is (\`invalid_request\`).`);
+ if (!value("state"))
+ warning("state is missing, so the application cannot tie the response to its own request.");
+ if (scopes.includes("openid") && !value("nonce"))
+ info("No nonce: the ID token carries none for the application to check.");
+ if (scopes.includes("offline_access") && !client.grantTypes.includes("refresh_token"))
+ warning(
+ "offline_access is requested but the refresh_token grant is missing, so no refresh token is usable.",
+ );
+ const prompt = value("prompt");
+ if (prompt?.split(" ").includes("select_account"))
+ error("prompt=select_account is not supported (`unsupported_prompt_select_account`).");
+
+ if (!list.some((finding) => finding.level === "error"))
+ info("The provider would accept this request and continue to sign-in and consent.");
+ return list;
+}
+
+/** The shortest path from `start` to `target` along `next`, both ends included, or null. */
+function shortestPath(start: string, target: string, next: (key: string) => readonly string[]) {
+ const previous = new Map([[start, null]]);
+ const queue = [start];
+ while (queue.length) {
+ const key = queue.shift()!;
+ if (key === target) {
+ const path = [key];
+ for (let step = previous.get(key); step; step = previous.get(step)) path.unshift(step);
+ return path;
+ }
+ for (const following of next(key))
+ if (!previous.has(following)) {
+ previous.set(following, key);
+ queue.push(following);
+ }
+ }
+ return null;
+}
+
+export type PermissionSource = {
+ /** A role the person holds directly: given by hand, proven by their accounts, or Master Admin. */
+ heldRole: string;
+ /** From the held role up the inheritance chain to the role that grants the permission. */
+ rolePath: string[];
+ /** From the permission that role grants down the implications to the one asked about. */
+ permissionPath: string[];
+};
+
+/**
+ * Why someone holds a permission: every way one of the roles they hold directly leads to it,
+ * through role inheritance and then permission implications. Empty when they do not hold it.
+ * Master Admin holds everything without a path.
+ */
+export function explainPermission(
+ catalog: RbacCatalog,
+ directRoles: readonly string[],
+ permission: string,
+): PermissionSource[] {
+ const roles = new Map(catalog.roles.map((entry) => [entry.key, entry]));
+ const permissions = new Map(catalog.permissions.map((entry) => [entry.key, entry]));
+ if (!permissions.has(permission)) return [];
+ if (directRoles.includes(MASTER_ADMIN_ROLE_KEY))
+ return [
+ {
+ heldRole: MASTER_ADMIN_ROLE_KEY,
+ rolePath: [MASTER_ADMIN_ROLE_KEY],
+ permissionPath: [permission],
+ },
+ ];
+ const parents = (key: string) =>
+ (roles.get(key)?.parents ?? []).filter((parent) => parent !== MASTER_ADMIN_ROLE_KEY);
+ const implies = (key: string) => permissions.get(key)?.implies ?? [];
+ const sources: PermissionSource[] = [];
+ for (const heldRole of directRoles) {
+ for (const role of catalog.roles) {
+ const rolePath = shortestPath(heldRole, role.key, parents);
+ if (!rolePath) continue;
+ for (const granted of role.permissions) {
+ const permissionPath = shortestPath(granted, permission, implies);
+ if (permissionPath) sources.push({ heldRole, rolePath, permissionPath });
+ }
+ }
+ }
+ return sources;
+}
diff --git a/src/auth/mcp-resource.ts b/src/auth/mcp-resource.ts
new file mode 100644
index 0000000..edbb0b6
--- /dev/null
+++ b/src/auth/mcp-resource.ts
@@ -0,0 +1,64 @@
+import { and, eq, isNull } from "drizzle-orm";
+import { db } from "../db/index";
+import { oauthClient, oauthClientResource } from "../db/schema";
+import { env } from "../env";
+import { MCP_PATH } from "./mcp-clients";
+
+/** The MCP server's resource identifier: the URL agents connect to, and their tokens' audience. */
+export const mcpResource = new URL(MCP_PATH, env.BETTER_AUTH_URL).href;
+
+/** Where agents read which authorization server protects the MCP server (RFC 9728). */
+export const mcpResourceMetadataUrl = new URL(
+ `/.well-known/oauth-protected-resource${MCP_PATH}`,
+ env.BETTER_AUTH_URL,
+).href;
+
+/** The OAuth issuer, as Better Auth names it from its base path. */
+export const issuer = new URL("/api/auth", env.BETTER_AUTH_URL).href;
+
+/**
+ * Clients registered by agents through dynamic registration. Every other client belongs to
+ * the shared pool (`OIDC_CLIENT_REFERENCE`); anonymous registration stores neither owner.
+ */
+export const isMcpClient = and(isNull(oauthClient.referenceId), isNull(oauthClient.userId));
+
+/**
+ * Finishes an agent's registration: links the client to the MCP server, the only resource
+ * it may get tokens for, and drops what the registrant said about itself besides its name.
+ * Logos, links and logout addresses would be shown or used as if PoliNetwork vouched for
+ * them, and an agent needs none of them.
+ */
+export async function finishMcpRegistration(clientId: string) {
+ await db.transaction(async (transaction) => {
+ const [client] = await transaction
+ .update(oauthClient)
+ .set({
+ uri: null,
+ icon: null,
+ iconDark: null,
+ tos: null,
+ policy: null,
+ contacts: null,
+ postLogoutRedirectUris: null,
+ backchannelLogoutUri: null,
+ backchannelLogoutSessionRequired: null,
+ enableEndSession: false,
+ })
+ .where(and(eq(oauthClient.clientId, clientId), isMcpClient))
+ .returning({ clientId: oauthClient.clientId });
+ if (!client) return;
+ await transaction
+ .insert(oauthClientResource)
+ .values({ id: crypto.randomUUID(), clientId, resourceId: mcpResource, createdAt: new Date() })
+ .onConflictDoNothing();
+ });
+}
+
+/** The scopes an agent's client may ask for, or null when the client is not an agent's. */
+export async function mcpClientScopes(clientId: string) {
+ const [client] = await db
+ .select({ scopes: oauthClient.scopes })
+ .from(oauthClient)
+ .where(and(eq(oauthClient.clientId, clientId), isMcpClient));
+ return client ? (client.scopes ?? []) : null;
+}
diff --git a/src/auth/mcp-server.ts b/src/auth/mcp-server.ts
new file mode 100644
index 0000000..536c729
--- /dev/null
+++ b/src/auth/mcp-server.ts
@@ -0,0 +1,860 @@
+import { McpServer, type CallToolResult } from "@modelcontextprotocol/server";
+import { and, desc, eq, like, lt } from "drizzle-orm";
+import { createLocalJWKSet, decodeJwt, decodeProtectedHeader, jwtVerify, type JWK } from "jose";
+import { z } from "zod";
+import { ActionError } from "@/lib/action-error";
+import { db } from "../db/index";
+import { rbacAuditEvent, user } from "../db/schema";
+import { toActionError } from "./guard";
+import { getIdentity } from "./identity";
+import { auth, tokenLifetimes } from "./index";
+import {
+ checkAuthorizationRequest,
+ diagnoseClient,
+ explainPermission,
+ type DiagnosisContext,
+} from "./mcp-diagnostics";
+import { issuer } from "./mcp-resource";
+import {
+ apiAccessEnabled,
+ checkClientDraft,
+ configuredResources,
+ createOidcClient,
+ deleteOidcClient,
+ listOidcClients,
+ rotateOidcClientSecret,
+ updateOidcClient,
+} from "./oidc-registry";
+import {
+ apiScopes,
+ draftFromClient,
+ emptyClientDraft,
+ oidcScopes,
+ type OidcClientDraft,
+ type OidcClientSummary,
+} from "./oidc-clients";
+import {
+ MASTER_ADMIN_ROLE_KEY,
+ effectiveRolePermissions,
+ emptyPermissionDraft,
+ emptyRoleDraft,
+ permissionDraftFrom,
+ roleDraftFrom,
+ staticRolesForStates,
+ type ManagedPermissionKey,
+ type RbacCatalog,
+} from "./rbac";
+import {
+ RbacError,
+ assignRole,
+ deletePermission,
+ deleteRole,
+ listRoleMembers,
+ loadCatalog,
+ savePermission,
+ saveRole,
+ searchUsers,
+ unassignRole,
+ withAuthorizedRbacRead,
+} from "./rbac-store";
+import { getUserDetail, listUsers } from "./user-directory";
+import { USER_PAGE_SIZES, userSearchSchema } from "./users";
+
+/** Who an MCP request acts for, as checked by the endpoint before the server is built. */
+export type McpPrincipal = {
+ userId: string;
+ clientId: string;
+ /** The managed `idp:*` permissions they held when the request arrived. */
+ permissions: string[];
+ roles: string[];
+};
+
+const INSTRUCTIONS = `PoliNetwork Auth is PoliNetwork's identity provider (OpenID Connect, built on Better Auth). These tools read and change its configuration on behalf of the person who connected you, with exactly their permissions: anything they could not do on the website is refused here too, and every change is recorded in the audit log under their name.
+
+To debug an application that cannot sign people in, start with get_application and diagnose_application. Pass the authorization URL the application sends to diagnose_application to see what /oauth2/authorize would answer, and decode tokens with inspect_token. get_server_info lists the endpoints, token formats and rules.
+
+To change an application, role or permission, read it first: the update tools merge your changes into what exists, and lists replace the old list entirely. Client secrets are returned once; hand them to the person, never store them anywhere else. Ask before deleting anything.`;
+
+/** A tool's input schema: every argument documented for the agent. */
+const roleRef = z.string().min(1).describe("The role's key, such as group-moderator, or its ID.");
+const permissionRef = z
+ .string()
+ .min(1)
+ .describe("The permission's key, such as membership:read, or its ID.");
+
+const draftFields = {
+ name: z.string().max(80).describe("Shown on the sign-in and consent pages."),
+ uri: z.string().describe("Homepage, an https URL. Empty for none."),
+ logo: z.string().describe("Logo, an https URL. Empty for none."),
+ logoDark: z.string().describe("Logo for dark backgrounds, an https URL. Empty for none."),
+ redirectUris: z
+ .array(z.string())
+ .max(50)
+ .describe("Exact callback URLs. Web apps need https on a public host."),
+ postLogoutRedirectUris: z
+ .array(z.string())
+ .max(50)
+ .describe("Where sign-out may send people back. Needs enableEndSession."),
+ contacts: z.array(z.string()).max(20).describe("Contact email addresses."),
+ tosUri: z.string().describe("Terms of service, an https URL. Empty for none."),
+ policyUri: z.string().describe("Privacy policy, an https URL. Empty for none."),
+ applicationType: z
+ .enum(["web", "native"])
+ .describe(
+ "web: https redirects on public hosts only. native: also http://localhost, http://127.0.0.1, http://[::1] and reverse-domain schemes such as org.polinetwork.app:/callback.",
+ ),
+ credential: z
+ .enum(["secret", "private-key", "public"])
+ .describe(
+ "secret: client_secret_basic, a secret is returned once. private-key: private_key_jwt with the public JWKS in `jwks`. public: no credential, PKCE only, sign-in only.",
+ ),
+ jwks: z
+ .string()
+ .max(20_000)
+ .describe("The public JWKS as JSON (Ed25519 or P-256 keys), only with credential private-key."),
+ signIn: z.boolean().describe("Signs people in with the authorization code flow."),
+ scopes: z
+ .array(z.string())
+ .max(10)
+ .describe(
+ `Scopes it may ask people for: ${oidcScopes.map((scope) => scope.id).join(", ")}. openid is required; offline_access adds refresh tokens.`,
+ ),
+ serviceAccess: z
+ .boolean()
+ .describe("Obtains tokens as itself with client credentials. Needs API access (Master Admin)."),
+ apiScopes: z
+ .array(z.string())
+ .max(20)
+ .describe("PoliNetwork API scopes it may ask for on behalf of people (Master Admin)."),
+ serviceScopes: z
+ .array(z.string())
+ .max(20)
+ .describe("PoliNetwork API scopes it may use as itself (Master Admin)."),
+ resources: z
+ .array(z.enum(["backend", "internal"]))
+ .max(2)
+ .describe("API resources its tokens may be addressed to; must cover the API scopes."),
+ enableEndSession: z.boolean().describe("Allows sign-out through /oauth2/end-session."),
+};
+
+function text(value: unknown): CallToolResult {
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
+}
+
+/** Refusals keep their message and field errors; anything unexpected is logged, not shown. */
+function failure(cause: unknown): CallToolResult {
+ const error = toActionError(cause);
+ const body =
+ error instanceof ActionError
+ ? { error: error.message, status: error.status, fields: error.fields }
+ : { error: "Something went wrong. Try again." };
+ return { isError: true, content: [{ type: "text", text: JSON.stringify(body, null, 2) }] };
+}
+
+async function respond(run: () => Promise): Promise {
+ try {
+ return text(await run());
+ } catch (cause) {
+ return failure(cause);
+ }
+}
+
+function diagnosisContext(): DiagnosisContext {
+ return { resources: configuredResources() };
+}
+
+function compactClient(client: OidcClientSummary) {
+ return {
+ clientId: client.clientId,
+ name: client.name,
+ credential: client.credential,
+ applicationType: client.applicationType,
+ grantTypes: client.grantTypes,
+ scopes: client.scopes,
+ redirectUris: client.redirectUris,
+ disabled: client.disabled,
+ restricted: client.restricted,
+ authorizedUsers: client.authorizedUsers,
+ updatedAt: client.updatedAt,
+ };
+}
+
+function findRole(catalog: RbacCatalog, ref: string) {
+ const found = catalog.roles.find((entry) => entry.key === ref || entry.id === ref);
+ if (!found) throw new RbacError(404, `No role ${ref}.`);
+ return found;
+}
+
+function findPermission(catalog: RbacCatalog, ref: string) {
+ const found = catalog.permissions.find((entry) => entry.key === ref || entry.id === ref);
+ if (!found) throw new RbacError(404, `No permission ${ref}.`);
+ return found;
+}
+
+/** Decodes and verifies a JWT this identity provider issued, and says what is wrong with it. */
+async function inspectToken(token: string) {
+ const findings: { level: "error" | "warning" | "info"; message: string }[] = [];
+ const trimmed = token.trim().replace(/^Bearer\s+/i, "");
+ if (trimmed.split(".").length !== 3)
+ return {
+ kind: "opaque",
+ findings: [
+ {
+ level: "info",
+ message:
+ "This is not a JWT. Access tokens are opaque unless the authorization and token requests name a `resource`; an opaque token works at /oauth2/userinfo and can be checked at /oauth2/introspect with the client's credentials.",
+ },
+ ],
+ };
+ let header: ReturnType;
+ let claims: ReturnType;
+ try {
+ header = decodeProtectedHeader(trimmed);
+ claims = decodeJwt(trimmed);
+ } catch {
+ return {
+ kind: "malformed",
+ findings: [{ level: "error", message: "The JWT cannot be decoded." }],
+ };
+ }
+ const kind =
+ header.typ === "at+jwt"
+ ? "access token"
+ : claims.events && typeof claims.events === "object"
+ ? "logout token"
+ : "ID token";
+ const jwks = await auth.api.getJwks();
+ if (!jwks.keys.some((key) => key.kid === header.kid))
+ findings.push({
+ level: "error",
+ message: `Key ${String(header.kid)} is not in this provider's JWKS: it was issued elsewhere or its key was retired.`,
+ });
+ try {
+ await jwtVerify(trimmed, createLocalJWKSet({ keys: jwks.keys as JWK[] }), {
+ currentDate: new Date((Number(claims.iat) || Date.now() / 1000) * 1000),
+ });
+ findings.push({ level: "info", message: `The ${String(header.alg)} signature is valid.` });
+ } catch (cause) {
+ findings.push({
+ level: "error",
+ message: `The signature does not verify: ${(cause as Error).message}`,
+ });
+ }
+ if (claims.iss !== issuer)
+ findings.push({ level: "error", message: `iss is ${String(claims.iss)}, not ${issuer}.` });
+ const now = Math.floor(Date.now() / 1000);
+ if (typeof claims.exp === "number" && claims.exp <= now)
+ findings.push({
+ level: "error",
+ message: `It expired ${now - claims.exp} seconds ago (${new Date(claims.exp * 1000).toISOString()}).`,
+ });
+ if (typeof claims.nbf === "number" && claims.nbf > now)
+ findings.push({ level: "error", message: "It is not valid yet (nbf is in the future)." });
+ if (typeof claims.iat === "number" && claims.iat > now + 60)
+ findings.push({
+ level: "warning",
+ message: "It was issued in the future: a clock on one side is wrong.",
+ });
+ if (kind === "ID token") {
+ const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
+ if (typeof claims.azp === "string" && !audience.includes(claims.azp))
+ findings.push({ level: "warning", message: "Its audience does not include its azp." });
+ findings.push({
+ level: "info",
+ message: `Its audience is the client ID ${String(claims.aud)}; the client must check that, the issuer, the RS256 signature and the expiry. It is not an access token: APIs must not accept it.`,
+ });
+ } else if (kind === "access token")
+ findings.push({
+ level: "info",
+ message: `It is addressed to ${JSON.stringify(claims.aud)} with scope "${typeof claims.scope === "string" ? claims.scope : ""}". The API must check that its own identifier is in aud.`,
+ });
+ return { kind, header, claims, findings };
+}
+
+/**
+ * The MCP server for one request, with the tools `principal` may use. Each tool calls the same
+ * store function as the matching page, which checks the permission again inside its own
+ * transaction, so a tool offered here never grants more than the website would.
+ */
+export function createMcpServer(principal: McpPrincipal) {
+ const server = new McpServer(
+ { name: "polinetwork-auth", title: "PoliNetwork Auth", version: "1.0.0" },
+ { instructions: INSTRUCTIONS },
+ );
+ const actor = principal.userId;
+ const can = (...permissions: ManagedPermissionKey[]) =>
+ permissions.every((permission) => principal.permissions.includes(permission));
+ const masterAdmin = principal.roles.includes(MASTER_ADMIN_ROLE_KEY);
+ const offered: string[] = [];
+ const offer = (name: string) => {
+ offered.push(name);
+ return name;
+ };
+
+ server.registerTool(
+ offer("whoami"),
+ {
+ title: "Who am I",
+ description:
+ "The person this connection acts for, their roles and permissions, and the tools they can use here.",
+ annotations: { readOnlyHint: true },
+ },
+ () =>
+ respond(async () => {
+ const [person] = await db
+ .select({ id: user.id, name: user.name })
+ .from(user)
+ .where(eq(user.id, actor));
+ const identity = await getIdentity(actor);
+ return {
+ user: person ?? { id: actor },
+ states: identity.states,
+ roles: identity.roles,
+ permissions: identity.permissions,
+ agentClientId: principal.clientId,
+ tools: offered,
+ };
+ }),
+ );
+
+ server.registerTool(
+ offer("get_server_info"),
+ {
+ title: "Identity provider settings",
+ description:
+ "The issuer, endpoints, scopes, token formats and lifetimes, and the rules clients must follow. Use it to check a client's configuration against what this provider expects.",
+ annotations: { readOnlyHint: true },
+ },
+ () =>
+ respond(async () => {
+ const metadata = await auth.api.getOpenIdConfig();
+ return {
+ issuer,
+ discovery: `${issuer}/.well-known/openid-configuration`,
+ metadata,
+ scopes: oidcScopes.map(({ id, label, description }) => ({ id, label, description })),
+ apiScopes: apiAccessEnabled()
+ ? apiScopes.map(({ id, description, resource }) => ({ id, description, resource }))
+ : [],
+ resources: configuredResources(),
+ tokens: {
+ idToken: `RS256 JWT, valid ${tokenLifetimes.idToken} s, aud is the client ID.`,
+ accessToken: `Opaque, valid ${tokenLifetimes.accessToken} s, usable at UserInfo. When the request names a configured resource, an EdDSA JWT (typ at+jwt) addressed to it.`,
+ clientCredentials: `EdDSA JWT for the requested resource, valid ${tokenLifetimes.clientCredentialsAccessToken} s.`,
+ refreshToken: `Only with offline_access and the refresh_token grant. Valid ${tokenLifetimes.refreshToken / 86_400} days and rotated on every use; reusing a rotated one within 10 s replays the same answer.`,
+ },
+ rules: [
+ "Every authorization request needs PKCE with S256 (code_challenge and code_challenge_method=S256).",
+ "redirect_uri must exactly match a registered one; only on 127.0.0.1 and [::1] may the port differ.",
+ "Web apps redirect to https on public hosts; native apps may also use http on localhost, 127.0.0.1 or [::1], or a reverse-domain scheme.",
+ "Clients authenticate with client_secret_basic, private_key_jwt (Ed25519 or P-256), or not at all when public.",
+ "polinetwork:identity adds states, roles, permissions and the Telegram ID under the claim named after /api/identity, plus space-separated polinetwork_* claims and a groups array.",
+ "Applications with API scopes, client credentials or resource links can be changed only by Master Admin.",
+ "Dynamic client registration is open to MCP agents only.",
+ ],
+ };
+ }),
+ );
+
+ if (can("idp:applications:read")) {
+ server.registerTool(
+ offer("list_applications"),
+ {
+ title: "List applications",
+ description: "Every application in the shared pool with its main settings.",
+ annotations: { readOnlyHint: true },
+ },
+ () => respond(async () => (await listOidcClients(actor)).map(compactClient)),
+ );
+
+ server.registerTool(
+ offer("get_application"),
+ {
+ title: "Get an application",
+ description:
+ "One application's full settings, and the same settings as an editable draft for update_application. Secrets are never returned.",
+ inputSchema: z.object({ clientId: z.string().min(1) }),
+ annotations: { readOnlyHint: true },
+ },
+ ({ clientId }) =>
+ respond(async () => {
+ const [client] = await listOidcClients(actor, clientId);
+ if (!client) throw new RbacError(404, "Application not found.");
+ return { application: client, draft: draftFromClient(client) };
+ }),
+ );
+
+ server.registerTool(
+ offer("diagnose_application"),
+ {
+ title: "Diagnose an application",
+ description:
+ "Finds misconfigurations in an application. With `authorizationUrl` (the URL the application sends people to, or its query string), also says what /oauth2/authorize would answer and why.",
+ inputSchema: z.object({
+ clientId: z.string().min(1),
+ authorizationUrl: z.string().max(8_000).optional(),
+ }),
+ annotations: { readOnlyHint: true },
+ },
+ ({ clientId, authorizationUrl }) =>
+ respond(async () => {
+ const [client] = await listOidcClients(actor, clientId);
+ if (!client) throw new RbacError(404, "Application not found.");
+ const context = diagnosisContext();
+ return {
+ application: compactClient(client),
+ settings: diagnoseClient(client, context),
+ ...(authorizationUrl
+ ? {
+ authorizationRequest: checkAuthorizationRequest(client, authorizationUrl, {
+ ...context,
+ authorizeEndpoint: `${issuer}/oauth2/authorize`,
+ }),
+ }
+ : {}),
+ };
+ }),
+ );
+ }
+
+ if (can("idp:applications:write")) {
+ server.registerTool(
+ offer("create_application"),
+ {
+ title: "Register an application",
+ description:
+ "Registers an application in the shared pool. Unset fields take the form's defaults: a web app with a client secret that signs people in with openid and profile. Returns the client ID, and the secret once.",
+ inputSchema: z.object(draftFields).partial().required({ name: true }),
+ },
+ (input) =>
+ respond(async () => {
+ const draft = checkClientDraft({ ...emptyClientDraft(), redirectUris: [], ...input });
+ const created = await createOidcClient(actor, draft);
+ const [client] = await listOidcClients(actor, created.clientId);
+ return {
+ clientId: created.clientId,
+ clientSecret: created.clientSecret,
+ application: client,
+ findings: client ? diagnoseClient(client, diagnosisContext()) : [],
+ };
+ }),
+ );
+
+ server.registerTool(
+ offer("update_application"),
+ {
+ title: "Change an application",
+ description:
+ "Merges `changes` into the application's current settings and saves them; a list given replaces the old list. `disabled` pauses sign-ins, `skipConsent` skips the consent screen. Switching to a secret returns the new secret once.",
+ inputSchema: z.object({
+ clientId: z.string().min(1),
+ changes: z.object(draftFields).partial().optional(),
+ disabled: z.boolean().optional(),
+ skipConsent: z.boolean().optional(),
+ }),
+ annotations: { idempotentHint: true },
+ },
+ ({ clientId, changes, disabled, skipConsent }) =>
+ respond(async () => {
+ const [current] = await listOidcClients(actor, clientId);
+ if (!current) throw new RbacError(404, "Application not found.");
+ const draft: OidcClientDraft | undefined = changes
+ ? checkClientDraft({ ...draftFromClient(current), ...changes })
+ : undefined;
+ const result = await updateOidcClient(actor, clientId, { draft, disabled, skipConsent });
+ if (!result) throw new RbacError(404, "Application not found.");
+ return {
+ application: result.client,
+ clientSecret: result.clientSecret,
+ findings: diagnoseClient(result.client, diagnosisContext()),
+ };
+ }),
+ );
+
+ server.registerTool(
+ offer("rotate_application_secret"),
+ {
+ title: "Rotate a client secret",
+ description:
+ "Replaces an application's client secret. The old one stops working at once; the new one is returned once.",
+ inputSchema: z.object({ clientId: z.string().min(1) }),
+ annotations: { destructiveHint: true },
+ },
+ ({ clientId }) =>
+ respond(async () => {
+ const result = await rotateOidcClientSecret(actor, clientId);
+ if (!result) throw new RbacError(404, "Application not found.");
+ return result;
+ }),
+ );
+
+ server.registerTool(
+ offer("delete_application"),
+ {
+ title: "Delete an application",
+ description:
+ "Deletes an application with its tokens and consents. `confirmName` must repeat its name exactly.",
+ inputSchema: z.object({ clientId: z.string().min(1), confirmName: z.string() }),
+ annotations: { destructiveHint: true },
+ },
+ ({ clientId, confirmName }) =>
+ respond(async () => {
+ const [client] = await listOidcClients(actor, clientId);
+ if (!client) throw new RbacError(404, "Application not found.");
+ if (client.name !== confirmName)
+ throw new RbacError(400, `confirmName must be exactly "${client.name}".`);
+ if (!(await deleteOidcClient(actor, clientId)))
+ throw new RbacError(404, "Application not found.");
+ return { deleted: clientId };
+ }),
+ );
+ }
+
+ server.registerTool(
+ offer("inspect_token"),
+ {
+ title: "Inspect a token",
+ description:
+ "Decodes a JWT this provider issued (ID token, access token or logout token), verifies its signature against the provider's keys, and explains what is wrong with it.",
+ inputSchema: z.object({ token: z.string().min(1).max(20_000) }),
+ annotations: { readOnlyHint: true },
+ },
+ ({ token }) => respond(() => inspectToken(token)),
+ );
+
+ if (can("idp:people:read")) {
+ server.registerTool(
+ offer("search_people"),
+ {
+ title: "Find people",
+ description:
+ "Up to 25 people matching a name, email, Polimi address, Telegram ID or user ID. With `role`, each result says whether they hold that role.",
+ inputSchema: z.object({ query: z.string().max(200), role: roleRef.optional() }),
+ annotations: { readOnlyHint: true },
+ },
+ ({ query, role }) =>
+ respond(async () => {
+ const roleId = role ? findRole(await loadCatalog(actor), role).id : undefined;
+ return searchUsers(actor, query, roleId);
+ }),
+ );
+ }
+
+ if (can("idp:users:read")) {
+ server.registerTool(
+ offer("list_users"),
+ {
+ title: "List users",
+ description:
+ "One page of the user directory. Filters take yes or no; `role` is a role key given by hand.",
+ inputSchema: z.object({
+ query: z.string().max(200).optional(),
+ role: z.string().max(64).optional(),
+ socio: z.enum(["yes", "no"]).optional(),
+ direttivo: z.enum(["yes", "no"]).optional(),
+ student: z.enum(["yes", "no"]).optional(),
+ telegram: z.enum(["yes", "no"]).optional(),
+ google: z.enum(["yes", "no"]).optional(),
+ polinetwork: z.enum(["yes", "no"]).optional(),
+ passkey: z.enum(["yes", "no"]).optional(),
+ sort: z.enum(["name", "newest", "oldest"]).optional(),
+ page: z.number().int().min(1).optional(),
+ pageSize: z.union(USER_PAGE_SIZES.map((size) => z.literal(size))).optional(),
+ }),
+ annotations: { readOnlyHint: true },
+ },
+ ({ query, ...filters }) =>
+ respond(() => listUsers(actor, userSearchSchema.parse({ ...filters, q: query }))),
+ );
+
+ server.registerTool(
+ offer("get_user"),
+ {
+ title: "Get a user",
+ description:
+ "One person: linked accounts, status checked live, and (with role access) the roles and permissions they hold.",
+ inputSchema: z.object({ userId: z.string().min(1) }),
+ annotations: { readOnlyHint: true },
+ },
+ ({ userId }) => respond(() => getUserDetail(actor, userId)),
+ );
+ }
+
+ if (can("idp:users:read", "idp:roles:read")) {
+ server.registerTool(
+ offer("explain_access"),
+ {
+ title: "Explain a permission",
+ description:
+ "Whether a person holds a permission, and every path that gives it to them: the role they hold, the roles it inherits, and the permissions that imply it.",
+ inputSchema: z.object({ userId: z.string().min(1), permission: permissionRef }),
+ annotations: { readOnlyHint: true },
+ },
+ ({ userId, permission }) =>
+ respond(async () => {
+ const [detail, catalog] = await Promise.all([
+ getUserDetail(actor, userId),
+ loadCatalog(actor),
+ ]);
+ const target = findPermission(catalog, permission);
+ const directRoles = [
+ ...(detail.assignedRoles ?? []).map((entry) => entry.key),
+ ...staticRolesForStates(detail.states),
+ ...((detail.roles ?? []).includes(MASTER_ADMIN_ROLE_KEY)
+ ? [MASTER_ADMIN_ROLE_KEY]
+ : []),
+ ];
+ return {
+ permission: target.key,
+ holds: (detail.permissions ?? []).includes(target.key),
+ directRoles,
+ sources: explainPermission(catalog, directRoles, target.key),
+ };
+ }),
+ );
+ }
+
+ if (can("idp:roles:read")) {
+ server.registerTool(
+ offer("list_roles"),
+ {
+ title: "List roles",
+ description:
+ "Every role with what it grants directly, what it inherits from, everything it confers in the end, and how many people hold it by hand.",
+ annotations: { readOnlyHint: true },
+ },
+ () =>
+ respond(async () => {
+ const catalog = await loadCatalog(actor);
+ return catalog.roles.map((role) => ({
+ ...role,
+ effectivePermissions: effectiveRolePermissions(catalog, role.key),
+ }));
+ }),
+ );
+
+ server.registerTool(
+ offer("get_role"),
+ {
+ title: "Get a role",
+ description:
+ "One role with its effective permissions, the roles that inherit from it, and a page of the people holding it by hand (100 at a time, continue with `after`).",
+ inputSchema: z.object({ role: roleRef, after: z.string().optional() }),
+ annotations: { readOnlyHint: true },
+ },
+ ({ role, after }) =>
+ respond(async () => {
+ const catalog = await loadCatalog(actor);
+ const found = findRole(catalog, role);
+ return {
+ role: found,
+ effectivePermissions: effectiveRolePermissions(catalog, found.key),
+ inheritedBy: catalog.roles
+ .filter((entry) => entry.parents.includes(found.key))
+ .map((entry) => entry.key),
+ members: found.managed
+ ? "Built-in roles are held through identity evidence, not by hand."
+ : await listRoleMembers(actor, found.id, after),
+ };
+ }),
+ );
+ }
+
+ if (can("idp:permissions:read")) {
+ server.registerTool(
+ offer("list_permissions"),
+ {
+ title: "List permissions",
+ description:
+ "Every permission with what it implies and how many roles grant it directly. Built-in (managed) ones are the idp:* keys.",
+ annotations: { readOnlyHint: true },
+ },
+ () => respond(async () => (await loadCatalog(actor)).permissions),
+ );
+ }
+
+ if (can("idp:roles:write")) {
+ const roleFields = {
+ key: z.string().describe("Lowercase letters, digits and . _ -, such as group-moderator."),
+ name: z.string(),
+ description: z.string(),
+ parents: z.array(z.string()).max(50).describe("Keys of the roles it inherits from."),
+ permissions: z.array(z.string()).max(200).describe("Keys of the permissions it grants."),
+ };
+ server.registerTool(
+ offer("create_role"),
+ {
+ title: "Create a role",
+ description:
+ "Creates a role. You can only create roles whose permissions you hold yourself, unless you are Master Admin.",
+ inputSchema: z.object(roleFields).partial().required({ key: true, name: true }),
+ },
+ (input) => respond(() => saveRole(actor, { ...emptyRoleDraft(), ...input })),
+ );
+
+ server.registerTool(
+ offer("update_role"),
+ {
+ title: "Change a role",
+ description:
+ "Merges `changes` into a role and saves it; a list given replaces the old list. Built-in roles keep their key.",
+ inputSchema: z.object({ role: roleRef, changes: z.object(roleFields).partial() }),
+ annotations: { idempotentHint: true },
+ },
+ ({ role, changes }) =>
+ respond(async () => {
+ const current = findRole(await loadCatalog(actor), role);
+ return saveRole(actor, { ...roleDraftFrom(current), ...changes }, current.id);
+ }),
+ );
+
+ server.registerTool(
+ offer("delete_role"),
+ {
+ title: "Delete a role",
+ description:
+ "Deletes a role, removing it from everyone who holds it and every role inheriting it. `confirmKey` must repeat its key.",
+ inputSchema: z.object({ role: roleRef, confirmKey: z.string() }),
+ annotations: { destructiveHint: true },
+ },
+ ({ role, confirmKey }) =>
+ respond(async () => {
+ const current = findRole(await loadCatalog(actor), role);
+ if (current.key !== confirmKey)
+ throw new RbacError(400, `confirmKey must be exactly "${current.key}".`);
+ await deleteRole(actor, current.id);
+ return { deleted: current.key };
+ }),
+ );
+ }
+
+ if (can("idp:roles:assign")) {
+ for (const action of ["assign", "unassign"] as const)
+ server.registerTool(
+ offer(`${action}_role`),
+ {
+ title: action === "assign" ? "Give a role" : "Take a role away",
+ description:
+ action === "assign"
+ ? "Gives a role to a person. You can only hand out access you hold yourself, unless you are Master Admin."
+ : "Takes a role given by hand away from a person.",
+ inputSchema: z.object({ role: roleRef, userId: z.string().min(1) }),
+ annotations: { idempotentHint: true, destructiveHint: action === "unassign" },
+ },
+ ({ role, userId }) =>
+ respond(async () => {
+ const found = findRole(await loadCatalog(actor), role);
+ if (action === "assign") await assignRole(actor, found.id, userId);
+ else await unassignRole(actor, found.id, userId);
+ return { [action === "assign" ? "assigned" : "unassigned"]: found.key, userId };
+ }),
+ );
+ }
+
+ if (can("idp:permissions:write")) {
+ const permissionFields = {
+ key: z.string().describe("Lowercase letters, digits and . _ - :, such as membership:read."),
+ name: z.string(),
+ description: z.string(),
+ implies: z.array(z.string()).max(50).describe("Keys of the permissions it also grants."),
+ };
+ server.registerTool(
+ offer("create_permission"),
+ {
+ title: "Create a permission",
+ description:
+ "Defines a permission. It confers nothing until a role grants it, and only someone who holds it can delegate it.",
+ inputSchema: z.object(permissionFields).partial().required({ key: true, name: true }),
+ },
+ (input) => respond(() => savePermission(actor, { ...emptyPermissionDraft(), ...input })),
+ );
+
+ server.registerTool(
+ offer("update_permission"),
+ {
+ title: "Change a permission",
+ description:
+ "Merges `changes` into a permission and saves it; a list given replaces the old list. Built-in permissions keep their key.",
+ inputSchema: z.object({
+ permission: permissionRef,
+ changes: z.object(permissionFields).partial(),
+ }),
+ annotations: { idempotentHint: true },
+ },
+ ({ permission, changes }) =>
+ respond(async () => {
+ const current = findPermission(await loadCatalog(actor), permission);
+ return savePermission(actor, { ...permissionDraftFrom(current), ...changes }, current.id);
+ }),
+ );
+
+ server.registerTool(
+ offer("delete_permission"),
+ {
+ title: "Delete a permission",
+ description:
+ "Deletes a permission and removes it from every role. Built-in permissions cannot be deleted. `confirmKey` must repeat its key.",
+ inputSchema: z.object({ permission: permissionRef, confirmKey: z.string() }),
+ annotations: { destructiveHint: true },
+ },
+ ({ permission, confirmKey }) =>
+ respond(async () => {
+ const current = findPermission(await loadCatalog(actor), permission);
+ if (current.key !== confirmKey)
+ throw new RbacError(400, `confirmKey must be exactly "${current.key}".`);
+ await deletePermission(actor, current.id);
+ return { deleted: current.key };
+ }),
+ );
+ }
+
+ if (masterAdmin) {
+ server.registerTool(
+ offer("list_audit_events"),
+ {
+ title: "Read the audit log",
+ description:
+ "Changes to roles, permissions, assignments, applications, Telegram links and student verification, newest first, with who made them and the state before and after. Master Admin only.",
+ inputSchema: z.object({
+ targetId: z
+ .string()
+ .optional()
+ .describe("A client ID, role ID, permission ID or user ID."),
+ actorId: z.string().optional(),
+ operation: z
+ .string()
+ .max(64)
+ .optional()
+ .describe("An operation or its prefix, such as oidc-client. or role.assign."),
+ before: z.iso.datetime().optional().describe("Only events older than this time."),
+ limit: z.number().int().min(1).max(100).default(25),
+ }),
+ annotations: { readOnlyHint: true },
+ },
+ ({ targetId, actorId, operation, before, limit }) =>
+ respond(() =>
+ withAuthorizedRbacRead(actor, ["idp:mcp"], async (transaction, _catalog, access) => {
+ if (!access.roles.includes(MASTER_ADMIN_ROLE_KEY))
+ throw new RbacError(403, "Only Master Admin may read the audit log.");
+ return transaction
+ .select()
+ .from(rbacAuditEvent)
+ .where(
+ and(
+ targetId ? eq(rbacAuditEvent.targetId, targetId) : undefined,
+ actorId ? eq(rbacAuditEvent.actorId, actorId) : undefined,
+ operation
+ ? like(rbacAuditEvent.operation, `${operation.replace(/[\\%_]/g, "\\$&")}%`)
+ : undefined,
+ before ? lt(rbacAuditEvent.createdAt, new Date(before)) : undefined,
+ ),
+ )
+ .orderBy(desc(rbacAuditEvent.createdAt))
+ .limit(limit);
+ }),
+ ),
+ );
+ }
+
+ return server;
+}
diff --git a/src/auth/mcp-token.ts b/src/auth/mcp-token.ts
new file mode 100644
index 0000000..1e1ccaf
--- /dev/null
+++ b/src/auth/mcp-token.ts
@@ -0,0 +1,74 @@
+import { and, eq } from "drizzle-orm";
+import { createLocalJWKSet, jwtVerify, type JWK } from "jose";
+import { db } from "../db/index";
+import { oauthClient, oauthConsent } from "../db/schema";
+import { auth } from "./index";
+import { MCP_SCOPE } from "./mcp-clients";
+import { isMcpClient, issuer, mcpResource } from "./mcp-resource";
+
+export type McpCaller = {
+ token: string;
+ userId: string;
+ clientId: string;
+ scopes: string[];
+ /** Seconds since the epoch. */
+ expiresAt: number;
+};
+
+/**
+ * The person and agent behind an MCP request, or null. The token is checked against this
+ * identity provider's own keys before any claim is trusted. It must be addressed to the MCP
+ * server, carry `idp:mcp`, and come from an agent's client that the person still has a
+ * consent for: disconnecting an agent deletes the consent, so its access ends at once rather
+ * than when the token expires. Whether the person may use the server is decided by the caller.
+ */
+export async function verifyMcpToken(authorization: string | null): Promise {
+ const bearer = /^Bearer ([A-Za-z0-9._~-]+)$/i.exec(authorization ?? "");
+ if (!bearer) return null;
+ const token = bearer[1]!;
+ let caller: McpCaller;
+ try {
+ const jwks = await auth.api.getJwks();
+ const { payload, protectedHeader } = await jwtVerify(
+ token,
+ createLocalJWKSet({ keys: jwks.keys as JWK[] }),
+ {
+ issuer,
+ audience: mcpResource,
+ algorithms: ["EdDSA"],
+ requiredClaims: ["sub", "iss", "aud", "iat", "exp"],
+ },
+ );
+ const scopes = typeof payload.scope === "string" ? payload.scope.split(" ") : [];
+ if (
+ protectedHeader.typ !== "at+jwt" ||
+ payload.pn_subject_type !== "user" ||
+ typeof payload.sub !== "string" ||
+ typeof payload.azp !== "string" ||
+ payload.client_id !== payload.azp ||
+ !scopes.includes(MCP_SCOPE) ||
+ // A sender-constrained token needs a DPoP proof this server does not check.
+ payload.cnf !== undefined
+ )
+ return null;
+ caller = {
+ token,
+ userId: payload.sub,
+ clientId: payload.azp,
+ scopes,
+ expiresAt: payload.exp!,
+ };
+ } catch {
+ return null;
+ }
+ const [client] = await db
+ .select({ disabled: oauthClient.disabled })
+ .from(oauthClient)
+ .innerJoin(
+ oauthConsent,
+ and(eq(oauthConsent.clientId, oauthClient.clientId), eq(oauthConsent.userId, caller.userId)),
+ )
+ .where(and(eq(oauthClient.clientId, caller.clientId), isMcpClient))
+ .limit(1);
+ return client && !client.disabled ? caller : null;
+}
diff --git a/src/auth/oidc-clients.ts b/src/auth/oidc-clients.ts
index 92fc982..282d9b7 100644
--- a/src/auth/oidc-clients.ts
+++ b/src/auth/oidc-clients.ts
@@ -40,9 +40,21 @@ export const oidcScopes = [
export type OidcScopeId = (typeof oidcScopes)[number]["id"];
export type OidcScopeInfo = { id: string; label: string; description: string; required: boolean };
+/** Scopes people may be asked for that applications in the pool cannot pick. */
+const otherScopes: OidcScopeInfo[] = [
+ {
+ id: "idp:mcp",
+ label: "Use PoliNetwork Auth as you",
+ description:
+ "An AI agent reads and changes this identity provider with your permissions, such as applications, roles and people you can manage.",
+ required: false,
+ },
+];
+
export function describeScope(id: string): OidcScopeInfo {
return (
- oidcScopes.find((scope) => scope.id === id) ?? {
+ oidcScopes.find((scope) => scope.id === id) ??
+ otherScopes.find((scope) => scope.id === id) ?? {
id,
label: id,
description: "Additional access requested by the application.",
diff --git a/src/auth/oidc-registry.ts b/src/auth/oidc-registry.ts
index 6d3efa5..2536b49 100644
--- a/src/auth/oidc-registry.ts
+++ b/src/auth/oidc-registry.ts
@@ -1,18 +1,22 @@
+import { generateRandomString } from "better-auth/crypto";
import { and, countDistinct, eq, inArray, sql } from "drizzle-orm";
import { db } from "../db/index";
import { oauthClient, oauthClientResource, oauthConsent } from "../db/schema";
import { env } from "../env";
-import { ClientJwksError, validateClientJwks } from "./client-jwks";
+import { validateClientJwks } from "./client-jwks";
import { issueClientSecret } from "./client-secret";
import { MASTER_ADMIN_ROLE_KEY } from "./rbac";
import { RbacError, withAuthorizedRbacRead, withAuthorizedRbacWrite } from "./rbac-store";
import {
clientRegistration,
credentialOf,
+ hasDraftErrors,
isRestrictedClient,
isRestrictedDraft,
+ normalizeClientDraft,
OIDC_CLIENT_REFERENCE,
parsePublicJwks,
+ validateClientDraft,
type OidcClientDraft,
type OidcClientSummary,
type OidcResourceKey,
@@ -33,6 +37,17 @@ export function apiAccessEnabled() {
return Boolean(env.OAUTH_BACKEND_RESOURCE_URI && env.OAUTH_INTERNAL_RESOURCE_URI);
}
+/**
+ * Normalizes a submitted draft and checks it with the rules the application form uses. The
+ * item lists carry nulls for valid entries; the form reads them back as they are.
+ */
+export function checkClientDraft(input: OidcClientDraft): OidcClientDraft {
+ const draft = normalizeClientDraft(input);
+ const fields = validateClientDraft(draft, { apiAccess: apiAccessEnabled() });
+ if (hasDraftErrors(fields)) throw new RbacError(400, "Check the highlighted fields.", fields);
+ return draft;
+}
+
function resourceKeys(resourceIds: string[]): OidcResourceKey[] {
const configured = configuredResources();
return (Object.keys(configured) as OidcResourceKey[]).filter((key) =>
@@ -168,65 +183,67 @@ async function syncResourceLinks(
}
/**
- * Links a newly created client to the resources its draft asks for. Master Admin only,
- * like every resource link.
+ * Registers a client in the shared pool, under the RBAC write lock that promotion to API
+ * access also takes, and links it to the resources its draft asks for. Only Master Admin may
+ * register one with API access. The provider's own registration endpoint is not used: it
+ * needs a browser session and writes outside the lock. The row matches what it would store.
+ * `clientSecret` is the first secret of a client that uses one, shown once.
*/
-export async function linkNewOidcClient(
- actorId: string,
- clientId: string,
- resources: OidcResourceKey[],
-) {
- if (!resources.length) return;
- await withAuthorizedRbacWrite(
- actorId,
- "idp:applications:write",
- async (transaction, _catalog, access) => {
- if (!access.roles.includes(MASTER_ADMIN_ROLE_KEY))
- throw new RbacError(403, "Only Master Admin may link API resources.");
- await transaction.execute(sql`select set_config('polinetwork.actor_id', ${actorId}, true)`);
- await syncResourceLinks(transaction, clientId, resources);
- },
- );
-}
-
-/** Stores a newly created client's dark logo, which the provider has no field for. */
-export async function setNewOidcClientDarkLogo(
+export async function createOidcClient(
actorId: string,
- clientId: string,
- logoDark: string,
-) {
- if (!logoDark) return;
- await changePooledClient(actorId, clientId, (transaction) =>
- transaction.update(oauthClient).set({ iconDark: logoDark }).where(pooledClient(clientId)),
- );
-}
-
-/**
- * Rechecks a newly created client's public keys against every other client, under the RBAC
- * write lock. The provider inserts the client outside that lock, so a key checked before the
- * insert may have been given to another client in the meantime. Checks the keys the client
- * holds now, which a locked save may already have replaced. Deletes the new client and
- * returns false when one of them belongs to another client.
- */
-export async function recheckNewOidcClientKeys(actorId: string, clientId: string) {
+ draft: OidcClientDraft,
+): Promise<{ clientId: string; clientSecret: string | null }> {
return withAuthorizedRbacWrite(
actorId,
"idp:applications:write",
async (transaction, _catalog, access) => {
+ if (isRestrictedDraft(draft) && !access.roles.includes(MASTER_ADMIN_ROLE_KEY))
+ throw new RbacError(403, "Only Master Admin may register applications with API access.");
await transaction.execute(sql`select set_config('polinetwork.actor_id', ${actorId}, true)`);
- const [current] = await readOidcClients(transaction, clientId);
- if (!current?.jwks) return true;
- try {
- await validateClientJwks(parsePublicJwks(current.jwks), transaction, clientId);
- return true;
- } catch (error) {
- if (!(error instanceof ClientJwksError)) throw error;
- // Unreachable in practice: promoting a client saves it under this lock, which
- // validates its keys. Never let a writer's cleanup delete a restricted client.
- if (current.restricted && !access.roles.includes(MASTER_ADMIN_ROLE_KEY)) throw error;
- await transaction.delete(oauthClient).where(pooledClient(clientId));
- return false;
- }
+ const registration = clientRegistration(draft);
+ const clientId = generateRandomString(32, "a-z", "A-Z");
+ let jwks: string | null = null;
+ let issued: { secret: string; stored: string } | null = null;
+ if (draft.credential === "private-key") {
+ const parsed = parsePublicJwks(draft.jwks);
+ await validateClientJwks(parsed, transaction);
+ jwks = JSON.stringify(parsed);
+ } else if (draft.credential === "secret") issued = issueClientSecret();
+ const now = new Date();
+ await transaction.insert(oauthClient).values({
+ id: crypto.randomUUID(),
+ clientId,
+ clientSecret: issued?.stored ?? null,
+ referenceId: OIDC_CLIENT_REFERENCE,
+ name: draft.name,
+ uri: draft.uri || null,
+ icon: draft.logo || null,
+ iconDark: draft.logoDark || null,
+ contacts: draft.contacts.length ? draft.contacts : null,
+ tos: draft.tosUri || null,
+ policy: draft.policyUri || null,
+ redirectUris: registration.redirectUris,
+ postLogoutRedirectUris: registration.postLogoutRedirectUris.length
+ ? registration.postLogoutRedirectUris
+ : null,
+ scopes: registration.scopes,
+ grantTypes: registration.grantTypes,
+ responseTypes: draft.signIn ? ["code"] : null,
+ clientCredentialsScopes: registration.clientCredentialsScopes,
+ enableEndSession: registration.enableEndSession,
+ applicationType: draft.applicationType,
+ tokenEndpointAuthMethod:
+ draft.credential === "public"
+ ? "none"
+ : draft.credential === "private-key"
+ ? "private_key_jwt"
+ : "client_secret_basic",
+ jwks,
+ createdAt: now,
+ updatedAt: now,
+ });
+ await syncResourceLinks(transaction, clientId, draft.resources);
+ return { clientId, clientSecret: issued?.secret ?? null };
},
);
}
diff --git a/src/auth/oidc.functions.ts b/src/auth/oidc.functions.ts
index 2b8a627..c36d10d 100644
--- a/src/auth/oidc.functions.ts
+++ b/src/auth/oidc.functions.ts
@@ -1,29 +1,14 @@
import { createServerFn } from "@tanstack/react-start";
-import { getRequest } from "@tanstack/react-start/server";
import { z } from "zod";
import { ActionError } from "@/lib/action-error";
import { validate } from "@/lib/validate";
-import { db } from "../db/index";
-import { validateClientJwks } from "./client-jwks";
-import { auth } from "./index";
import { permissionMiddleware } from "./middleware";
-import { canAdministerIdp } from "./oidc-admin";
-import {
- clientRegistration,
- hasDraftErrors,
- isRestrictedDraft,
- normalizeClientDraft,
- parsePublicJwks,
- validateClientDraft,
- type OidcClientDraft,
-} from "./oidc-clients";
import {
apiAccessEnabled,
+ checkClientDraft,
+ createOidcClient,
deleteOidcClient,
- linkNewOidcClient,
listOidcClients,
- recheckNewOidcClientKeys,
- setNewOidcClientDarkLogo,
rotateOidcClientSecret,
updateOidcClient,
} from "./oidc-registry";
@@ -72,86 +57,16 @@ const draftSchema = z.object({
enableEndSession: z.boolean(),
});
-/** Normalizes and checks a submitted draft with the rules the form uses. */
-function checkedDraft(input: OidcClientDraft) {
- const draft = normalizeClientDraft(input);
- const fields = validateClientDraft(draft, { apiAccess: apiAccessEnabled() });
- // The item lists carry nulls for valid entries; the form reads them back as they are.
- if (hasDraftErrors(fields)) throw new ActionError(400, "Check the highlighted fields.", fields);
- return draft;
-}
-
/**
* Registers a client in the shared pool and links it to the resources it asks for. Only
- * Master Admin may register one with API access. A failed link still returns the new client
- * ID; saving the application again retries it.
+ * Master Admin may register one with API access.
*/
export const createOidcClientFn = createServerFn({ method: "POST" })
.middleware([permissionMiddleware("idp:applications:write")])
.validator(validate(z.object({ draft: draftSchema })))
.handler(async ({ context, data }) => {
- const actorId = context.session.userId;
- const draft = checkedDraft(data.draft);
- if (isRestrictedDraft(draft) && !(await canAdministerIdp(actorId, db, true)))
- throw new ActionError(403, "Only Master Admin may register applications with API access.");
- const jwks = draft.credential === "private-key" ? parsePublicJwks(draft.jwks) : undefined;
- if (jwks) await validateClientJwks(jwks, db);
-
- const registration = clientRegistration(draft);
- let created: { client_id: string; client_secret?: string };
- try {
- created = await auth.api.adminCreateOAuthClient({
- headers: getRequest().headers,
- body: {
- client_name: draft.name,
- ...(draft.uri ? { client_uri: draft.uri } : {}),
- ...(draft.logo ? { logo_uri: draft.logo } : {}),
- ...(draft.tosUri ? { tos_uri: draft.tosUri } : {}),
- ...(draft.policyUri ? { policy_uri: draft.policyUri } : {}),
- ...(draft.contacts.length ? { contacts: draft.contacts } : {}),
- ...(registration.redirectUris.length
- ? { redirect_uris: registration.redirectUris, response_types: ["code" as const] }
- : {}),
- ...(registration.postLogoutRedirectUris.length
- ? { post_logout_redirect_uris: registration.postLogoutRedirectUris }
- : {}),
- scope: registration.scopes.join(" "),
- grant_types: registration.grantTypes,
- application_type: draft.applicationType,
- token_endpoint_auth_method:
- draft.credential === "public"
- ? "none"
- : draft.credential === "private-key"
- ? "private_key_jwt"
- : "client_secret_basic",
- ...(jwks ? { jwks } : {}),
- ...(registration.clientCredentialsScopes.length
- ? { client_credentials_scopes: registration.clientCredentialsScopes }
- : {}),
- ...(registration.enableEndSession ? { enable_end_session: true } : {}),
- },
- });
- } catch {
- throw new ActionError(
- 400,
- "Unable to register this application. Check its redirect URIs and public key.",
- );
- }
-
- if (jwks && !(await recheckNewOidcClientKeys(actorId, created.client_id)))
- throw new ActionError(409, "This public key already belongs to another client.");
- await setNewOidcClientDarkLogo(actorId, created.client_id, draft.logoDark);
- let linked = true;
- try {
- await linkNewOidcClient(actorId, created.client_id, draft.resources);
- } catch {
- linked = false;
- }
- return {
- clientId: created.client_id,
- clientSecret: created.client_secret ?? null,
- linked,
- };
+ const created = await createOidcClient(context.session.userId, checkClientDraft(data.draft));
+ return { ...created, linked: true };
});
/**
@@ -172,7 +87,7 @@ export const updateOidcClientFn = createServerFn({ method: "POST" })
)
.handler(async ({ context, data }) => {
const { clientId, disabled, skipConsent } = data;
- const draft = data.draft ? checkedDraft(data.draft) : undefined;
+ const draft = data.draft ? checkClientDraft(data.draft) : undefined;
const result = await updateOidcClient(context.session.userId, clientId, {
draft,
disabled,
diff --git a/src/auth/rbac.test.ts b/src/auth/rbac.test.ts
index ae0166a..baa4a16 100644
--- a/src/auth/rbac.test.ts
+++ b/src/auth/rbac.test.ts
@@ -272,7 +272,7 @@ describe("permissions the identity provider defines itself", () => {
),
};
- it("covers roles, permissions, applications, and people", () => {
+ it("covers roles, permissions, applications, people, and the MCP server", () => {
expect(MANAGED_PERMISSIONS.map((entry) => entry.key)).toEqual([
"idp:people:read",
"idp:users:read",
@@ -284,6 +284,7 @@ describe("permissions the identity provider defines itself", () => {
"idp:roles:assign",
"idp:applications:read",
"idp:applications:write",
+ "idp:mcp",
]);
for (const entry of MANAGED_PERMISSIONS) expect(isManagedPermissionKey(entry.key)).toBe(true);
expect(isManagedPermissionKey("membership:read")).toBe(false);
diff --git a/src/auth/rbac.ts b/src/auth/rbac.ts
index 4c56318..2b56356 100644
--- a/src/auth/rbac.ts
+++ b/src/auth/rbac.ts
@@ -147,6 +147,13 @@ export const MANAGED_PERMISSIONS = [
"Register applications, edit their redirect URIs and scopes, rotate secrets, and delete them.",
implies: ["idp:applications:read"],
},
+ {
+ key: "idp:mcp",
+ name: "Use the MCP server",
+ description:
+ "Connect an AI agent to this identity provider. The agent can only do what the person's other permissions allow.",
+ implies: [],
+ },
] as const;
export type ManagedPermission = (typeof MANAGED_PERMISSIONS)[number];
diff --git a/src/components/account/mcp-panel.tsx b/src/components/account/mcp-panel.tsx
new file mode 100644
index 0000000..a7571ed
--- /dev/null
+++ b/src/components/account/mcp-panel.tsx
@@ -0,0 +1,104 @@
+import { useState } from "react";
+import { Bot, Unplug } from "lucide-react";
+import type { AccountDetails } from "@/auth/account.functions";
+import { mcpSetupCommands } from "@/auth/mcp-clients";
+import { CopyButton } from "@/components/copy-button";
+import { Button, buttonVariants } from "@/components/ui/button";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Panel, Spinner } from "./panel";
+import { TimeAgo } from "./time";
+import type { AccountActions } from "./use-account-actions";
+
+type Mcp = NonNullable;
+
+function CopyRow({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+
+ {value}
+
+
+
+ );
+}
+
+/** Where agents connect to the MCP server, and the agents this person has connected. */
+export function McpPanel({ mcp, actions }: { mcp: Mcp; actions: AccountActions }) {
+ const [pending, setPending] = useState<{ clientId: string; name: string } | null>(null);
+ const locked = actions.busy !== null;
+ return (
+
+
+
+ {mcpSetupCommands(mcp.serverUrl).map((entry) => (
+
+ ))}
+ {mcp.agents.map((agent) => {
+ const name = agent.name ?? "Agent";
+ return (
+ -
+
+
+
{name}
+
+ {agent.redirectHost && <>{agent.redirectHost} · >}
+ {agent.lastUsedAt ? (
+ <>
+ Active
+ >
+ ) : (
+ <>
+ Connected
+ >
+ )}
+
+
+
+
+ );
+ })}
+
+
+ !open && setPending(null)}>
+
+
+ Disconnect {pending?.name}?
+
+
+
+ Cancel
+
+ {
+ if (pending) void actions.disconnectAgent(pending.clientId);
+ }}
+ >
+
+ Disconnect
+
+
+
+
+
+ );
+}
diff --git a/src/components/account/use-account-actions.ts b/src/components/account/use-account-actions.ts
index 6ea568f..b409bf4 100644
--- a/src/components/account/use-account-actions.ts
+++ b/src/components/account/use-account-actions.ts
@@ -1,7 +1,12 @@
import { useRouter } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
-import { revokeOtherSessionsFn, revokeSessionFn, unlinkAccountFn } from "@/auth/account.functions";
+import {
+ disconnectMcpAgentFn,
+ revokeOtherSessionsFn,
+ revokeSessionFn,
+ unlinkAccountFn,
+} from "@/auth/account.functions";
import { authClient } from "@/auth/client";
import { errorMessage } from "@/lib/action-error";
@@ -102,6 +107,16 @@ export function useAccountActions() {
},
"Unable to sign out of the other sessions.",
),
+ disconnectAgent: (clientId: string) =>
+ run(
+ clientId,
+ async () => {
+ await disconnectMcpAgentFn({ data: { clientId } });
+ await refresh();
+ return "Agent disconnected.";
+ },
+ "Unable to disconnect this agent.",
+ ),
};
}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 5b09a6c..468be0b 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -18,11 +18,15 @@ import { Route as PasskeyRouteImport } from './routes/passkey'
import { Route as UsersRouteRouteImport } from './routes/users/route'
import { Route as AccessIndexRouteImport } from './routes/access/index'
import { Route as ApiIdentityRouteImport } from './routes/api/identity'
+import { Route as ApiMcpRouteImport } from './routes/api/mcp'
import { Route as ApplicationsIndexRouteImport } from './routes/applications/index'
import { Route as ApplicationsClientIdRouteImport } from './routes/applications/$clientId'
import { Route as ApplicationsNewRouteImport } from './routes/applications/new'
import { Route as UsersIndexRouteImport } from './routes/users/index'
import { Route as UsersUserIdRouteImport } from './routes/users/$userId'
+import { Route as DotwellKnownOauthAuthorizationServerSplatRouteImport } from './routes/[.]well-known/oauth-authorization-server.$'
+import { Route as DotwellKnownOauthProtectedResourceSplatRouteImport } from './routes/[.]well-known/oauth-protected-resource.$'
+import { Route as DotwellKnownOpenidConfigurationSplatRouteImport } from './routes/[.]well-known/openid-configuration.$'
import { Route as AccessPermissionsIndexRouteImport } from './routes/access/permissions/index'
import { Route as AccessPermissionsPermissionIdRouteImport } from './routes/access/permissions/$permissionId'
import { Route as AccessPermissionsNewRouteImport } from './routes/access/permissions/new'
@@ -79,6 +83,11 @@ const ApiIdentityRoute = ApiIdentityRouteImport.update({
path: '/api/identity',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiMcpRoute = ApiMcpRouteImport.update({
+ id: '/api/mcp',
+ path: '/api/mcp',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApplicationsIndexRoute = ApplicationsIndexRouteImport.update({
id: '/',
path: '/',
@@ -104,6 +113,24 @@ const UsersUserIdRoute = UsersUserIdRouteImport.update({
path: '/$userId',
getParentRoute: () => UsersRouteRoute,
} as any)
+const DotwellKnownOauthAuthorizationServerSplatRoute =
+ DotwellKnownOauthAuthorizationServerSplatRouteImport.update({
+ id: '/.well-known/oauth-authorization-server/$',
+ path: '/.well-known/oauth-authorization-server/$',
+ getParentRoute: () => rootRouteImport,
+ } as any)
+const DotwellKnownOauthProtectedResourceSplatRoute =
+ DotwellKnownOauthProtectedResourceSplatRouteImport.update({
+ id: '/.well-known/oauth-protected-resource/$',
+ path: '/.well-known/oauth-protected-resource/$',
+ getParentRoute: () => rootRouteImport,
+ } as any)
+const DotwellKnownOpenidConfigurationSplatRoute =
+ DotwellKnownOpenidConfigurationSplatRouteImport.update({
+ id: '/.well-known/openid-configuration/$',
+ path: '/.well-known/openid-configuration/$',
+ getParentRoute: () => rootRouteImport,
+ } as any)
const AccessPermissionsIndexRoute = AccessPermissionsIndexRouteImport.update({
id: '/permissions/',
path: '/permissions/',
@@ -166,12 +193,16 @@ export interface FileRoutesByFullPath {
'/logout': typeof LogoutRoute
'/passkey': typeof PasskeyRoute
'/api/identity': typeof ApiIdentityRoute
+ '/api/mcp': typeof ApiMcpRoute
'/applications/$clientId': typeof ApplicationsClientIdRoute
'/applications/new': typeof ApplicationsNewRoute
'/users/$userId': typeof UsersUserIdRoute
'/access/': typeof AccessIndexRoute
'/applications/': typeof ApplicationsIndexRoute
'/users/': typeof UsersIndexRoute
+ '/.well-known/oauth-authorization-server/$': typeof DotwellKnownOauthAuthorizationServerSplatRoute
+ '/.well-known/oauth-protected-resource/$': typeof DotwellKnownOauthProtectedResourceSplatRoute
+ '/.well-known/openid-configuration/$': typeof DotwellKnownOpenidConfigurationSplatRoute
'/access/permissions/$permissionId': typeof AccessPermissionsPermissionIdRoute
'/access/permissions/new': typeof AccessPermissionsNewRoute
'/access/roles/$roleId': typeof AccessRolesRoleIdRoute
@@ -189,12 +220,16 @@ export interface FileRoutesByTo {
'/logout': typeof LogoutRoute
'/passkey': typeof PasskeyRoute
'/api/identity': typeof ApiIdentityRoute
+ '/api/mcp': typeof ApiMcpRoute
'/applications/$clientId': typeof ApplicationsClientIdRoute
'/applications/new': typeof ApplicationsNewRoute
'/users/$userId': typeof UsersUserIdRoute
'/access': typeof AccessIndexRoute
'/applications': typeof ApplicationsIndexRoute
'/users': typeof UsersIndexRoute
+ '/.well-known/oauth-authorization-server/$': typeof DotwellKnownOauthAuthorizationServerSplatRoute
+ '/.well-known/oauth-protected-resource/$': typeof DotwellKnownOauthProtectedResourceSplatRoute
+ '/.well-known/openid-configuration/$': typeof DotwellKnownOpenidConfigurationSplatRoute
'/access/permissions/$permissionId': typeof AccessPermissionsPermissionIdRoute
'/access/permissions/new': typeof AccessPermissionsNewRoute
'/access/roles/$roleId': typeof AccessRolesRoleIdRoute
@@ -216,12 +251,16 @@ export interface FileRoutesById {
'/logout': typeof LogoutRoute
'/passkey': typeof PasskeyRoute
'/api/identity': typeof ApiIdentityRoute
+ '/api/mcp': typeof ApiMcpRoute
'/applications/$clientId': typeof ApplicationsClientIdRoute
'/applications/new': typeof ApplicationsNewRoute
'/users/$userId': typeof UsersUserIdRoute
'/access/': typeof AccessIndexRoute
'/applications/': typeof ApplicationsIndexRoute
'/users/': typeof UsersIndexRoute
+ '/.well-known/oauth-authorization-server/$': typeof DotwellKnownOauthAuthorizationServerSplatRoute
+ '/.well-known/oauth-protected-resource/$': typeof DotwellKnownOauthProtectedResourceSplatRoute
+ '/.well-known/openid-configuration/$': typeof DotwellKnownOpenidConfigurationSplatRoute
'/access/permissions/$permissionId': typeof AccessPermissionsPermissionIdRoute
'/access/permissions/new': typeof AccessPermissionsNewRoute
'/access/roles/$roleId': typeof AccessRolesRoleIdRoute
@@ -244,12 +283,16 @@ export interface FileRouteTypes {
| '/logout'
| '/passkey'
| '/api/identity'
+ | '/api/mcp'
| '/applications/$clientId'
| '/applications/new'
| '/users/$userId'
| '/access/'
| '/applications/'
| '/users/'
+ | '/.well-known/oauth-authorization-server/$'
+ | '/.well-known/oauth-protected-resource/$'
+ | '/.well-known/openid-configuration/$'
| '/access/permissions/$permissionId'
| '/access/permissions/new'
| '/access/roles/$roleId'
@@ -267,12 +310,16 @@ export interface FileRouteTypes {
| '/logout'
| '/passkey'
| '/api/identity'
+ | '/api/mcp'
| '/applications/$clientId'
| '/applications/new'
| '/users/$userId'
| '/access'
| '/applications'
| '/users'
+ | '/.well-known/oauth-authorization-server/$'
+ | '/.well-known/oauth-protected-resource/$'
+ | '/.well-known/openid-configuration/$'
| '/access/permissions/$permissionId'
| '/access/permissions/new'
| '/access/roles/$roleId'
@@ -293,12 +340,16 @@ export interface FileRouteTypes {
| '/logout'
| '/passkey'
| '/api/identity'
+ | '/api/mcp'
| '/applications/$clientId'
| '/applications/new'
| '/users/$userId'
| '/access/'
| '/applications/'
| '/users/'
+ | '/.well-known/oauth-authorization-server/$'
+ | '/.well-known/oauth-protected-resource/$'
+ | '/.well-known/openid-configuration/$'
| '/access/permissions/$permissionId'
| '/access/permissions/new'
| '/access/roles/$roleId'
@@ -320,6 +371,10 @@ export interface RootRouteChildren {
LogoutRoute: typeof LogoutRoute
PasskeyRoute: typeof PasskeyRoute
ApiIdentityRoute: typeof ApiIdentityRoute
+ ApiMcpRoute: typeof ApiMcpRoute
+ DotwellKnownOauthAuthorizationServerSplatRoute: typeof DotwellKnownOauthAuthorizationServerSplatRoute
+ DotwellKnownOauthProtectedResourceSplatRoute: typeof DotwellKnownOauthProtectedResourceSplatRoute
+ DotwellKnownOpenidConfigurationSplatRoute: typeof DotwellKnownOpenidConfigurationSplatRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiDevLoginRoute: typeof ApiDevLoginRoute
ApiInternalAccessSnapshotRoute: typeof ApiInternalAccessSnapshotRoute
@@ -391,6 +446,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiIdentityRouteImport
parentRoute: typeof rootRouteImport
}
+ '/api/mcp': {
+ id: '/api/mcp'
+ path: '/api/mcp'
+ fullPath: '/api/mcp'
+ preLoaderRoute: typeof ApiMcpRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/applications/': {
id: '/applications/'
path: '/'
@@ -426,6 +488,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof UsersUserIdRouteImport
parentRoute: typeof UsersRouteRoute
}
+ '/.well-known/oauth-authorization-server/$': {
+ id: '/.well-known/oauth-authorization-server/$'
+ path: '/.well-known/oauth-authorization-server/$'
+ fullPath: '/.well-known/oauth-authorization-server/$'
+ preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerSplatRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/.well-known/oauth-protected-resource/$': {
+ id: '/.well-known/oauth-protected-resource/$'
+ path: '/.well-known/oauth-protected-resource/$'
+ fullPath: '/.well-known/oauth-protected-resource/$'
+ preLoaderRoute: typeof DotwellKnownOauthProtectedResourceSplatRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/.well-known/openid-configuration/$': {
+ id: '/.well-known/openid-configuration/$'
+ path: '/.well-known/openid-configuration/$'
+ fullPath: '/.well-known/openid-configuration/$'
+ preLoaderRoute: typeof DotwellKnownOpenidConfigurationSplatRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/access/permissions/': {
id: '/access/permissions/'
path: '/permissions'
@@ -561,6 +644,13 @@ const rootRouteChildren: RootRouteChildren = {
LogoutRoute: LogoutRoute,
PasskeyRoute: PasskeyRoute,
ApiIdentityRoute: ApiIdentityRoute,
+ ApiMcpRoute: ApiMcpRoute,
+ DotwellKnownOauthAuthorizationServerSplatRoute:
+ DotwellKnownOauthAuthorizationServerSplatRoute,
+ DotwellKnownOauthProtectedResourceSplatRoute:
+ DotwellKnownOauthProtectedResourceSplatRoute,
+ DotwellKnownOpenidConfigurationSplatRoute:
+ DotwellKnownOpenidConfigurationSplatRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiDevLoginRoute: ApiDevLoginRoute,
ApiInternalAccessSnapshotRoute: ApiInternalAccessSnapshotRoute,
diff --git a/src/routes/[.]well-known/oauth-authorization-server.$.ts b/src/routes/[.]well-known/oauth-authorization-server.$.ts
new file mode 100644
index 0000000..614a3fe
--- /dev/null
+++ b/src/routes/[.]well-known/oauth-authorization-server.$.ts
@@ -0,0 +1,23 @@
+import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
+import { createFileRoute } from "@tanstack/react-router";
+import { auth } from "@/auth";
+
+const metadata = oauthProviderAuthServerMetadata(auth, {
+ headers: { "Access-Control-Allow-Origin": "*" },
+});
+
+/**
+ * The authorization server metadata where RFC 8414 puts it for an issuer with a path
+ * (`/.well-known/oauth-authorization-server/api/auth`), which MCP agents try first, and at
+ * the root, where agents from before protected resource metadata look.
+ */
+export const Route = createFileRoute("/.well-known/oauth-authorization-server/$")({
+ server: {
+ handlers: {
+ GET: ({ params, request }) =>
+ !params._splat || params._splat === "api/auth"
+ ? metadata(request)
+ : Response.json({ error: "not_found" }, { status: 404 }),
+ },
+ },
+});
diff --git a/src/routes/[.]well-known/oauth-protected-resource.$.ts b/src/routes/[.]well-known/oauth-protected-resource.$.ts
new file mode 100644
index 0000000..c82fe63
--- /dev/null
+++ b/src/routes/[.]well-known/oauth-protected-resource.$.ts
@@ -0,0 +1,34 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { MCP_PATH, MCP_SCOPE } from "@/auth/mcp-clients";
+import { issuer, mcpResource } from "@/auth/mcp-resource";
+
+/**
+ * The MCP server's protected resource metadata (RFC 9728), at the path-suffixed location and
+ * at the root for agents that look there. It names this identity provider as the server that
+ * signs agents in, and the scopes to ask for.
+ */
+export const Route = createFileRoute("/.well-known/oauth-protected-resource/$")({
+ server: {
+ handlers: {
+ GET: ({ params }) => {
+ if (params._splat && `/${params._splat}` !== MCP_PATH)
+ return Response.json({ error: "not_found" }, { status: 404 });
+ return Response.json(
+ {
+ resource: mcpResource,
+ authorization_servers: [issuer],
+ scopes_supported: [MCP_SCOPE, "offline_access"],
+ bearer_methods_supported: ["header"],
+ resource_name: "PoliNetwork Auth",
+ },
+ {
+ headers: {
+ "Access-Control-Allow-Origin": "*",
+ "Cache-Control": "public, max-age=3600",
+ },
+ },
+ );
+ },
+ },
+ },
+});
diff --git a/src/routes/[.]well-known/openid-configuration.$.ts b/src/routes/[.]well-known/openid-configuration.$.ts
new file mode 100644
index 0000000..96f0bc6
--- /dev/null
+++ b/src/routes/[.]well-known/openid-configuration.$.ts
@@ -0,0 +1,23 @@
+import { oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
+import { createFileRoute } from "@tanstack/react-router";
+import { auth } from "@/auth";
+
+const metadata = oauthProviderOpenIdConfigMetadata(auth, {
+ headers: { "Access-Control-Allow-Origin": "*" },
+});
+
+/**
+ * OpenID Connect discovery with the issuer's path appended to the well-known path, which MCP
+ * agents try before `/api/auth/.well-known/openid-configuration`. Only the issuer's own path
+ * is answered: the root would describe an issuer that does not exist.
+ */
+export const Route = createFileRoute("/.well-known/openid-configuration/$")({
+ server: {
+ handlers: {
+ GET: ({ params, request }) =>
+ params._splat === "api/auth"
+ ? metadata(request)
+ : Response.json({ error: "not_found" }, { status: 404 }),
+ },
+ },
+});
diff --git a/src/routes/api/mcp.ts b/src/routes/api/mcp.ts
new file mode 100644
index 0000000..356d969
--- /dev/null
+++ b/src/routes/api/mcp.ts
@@ -0,0 +1,89 @@
+import { createMcpHandler } from "@modelcontextprotocol/server";
+import { createFileRoute } from "@tanstack/react-router";
+import { logAuthorizationDenial } from "@/auth/denial-log";
+import { getIdentity } from "@/auth/identity";
+import { MCP_SCOPE } from "@/auth/mcp-clients";
+import { mcpResource, mcpResourceMetadataUrl } from "@/auth/mcp-resource";
+import { createMcpServer, type McpPrincipal } from "@/auth/mcp-server";
+import { verifyMcpToken } from "@/auth/mcp-token";
+import { MANAGED_PERMISSION_KEYS } from "@/auth/rbac";
+
+const headers = { "Cache-Control": "no-store" };
+
+// Every request is answered by a fresh server built for its principal, without sessions:
+// the agent's token and the person's permissions are checked on each one.
+const handler = createMcpHandler(
+ ({ authInfo }) => createMcpServer(authInfo?.extra?.principal as McpPrincipal),
+ { onerror: (error) => console.error("MCP request failed", error) },
+);
+
+/**
+ * The RFC 6750 challenge that sends an agent to the protected resource metadata, and from
+ * there to sign-in. It asks for offline access so the agent can refresh instead of making
+ * the person sign in again every few minutes.
+ */
+function unauthorized(error?: string) {
+ const challenge = [
+ `resource_metadata="${mcpResourceMetadataUrl}"`,
+ `scope="${MCP_SCOPE} offline_access"`,
+ ...(error ? [`error="${error}"`] : []),
+ ].join(", ");
+ return Response.json(
+ { error: error ?? "unauthorized", error_description: "Sign in to use the MCP server." },
+ { status: 401, headers: { ...headers, "WWW-Authenticate": `Bearer ${challenge}` } },
+ );
+}
+
+async function handle(request: Request) {
+ const authorization = request.headers.get("authorization");
+ const caller = await verifyMcpToken(authorization);
+ if (!caller) return unauthorized(authorization ? "invalid_token" : undefined);
+ let identity: Awaited>;
+ try {
+ identity = await getIdentity(caller.userId);
+ } catch {
+ return Response.json(
+ { error: "temporarily_unavailable", error_description: "Authorization unavailable." },
+ { status: 503, headers },
+ );
+ }
+ // Checked on every request, so taking the permission away ends access at once.
+ if (!identity.permissions.includes(MCP_SCOPE)) {
+ logAuthorizationDenial(caller.userId, "mcp", [MCP_SCOPE]);
+ return Response.json(
+ {
+ error: "access_denied",
+ error_description: `Your account needs the ${MCP_SCOPE} permission to use the MCP server.`,
+ },
+ { status: 403, headers },
+ );
+ }
+ const principal: McpPrincipal = {
+ userId: caller.userId,
+ clientId: caller.clientId,
+ permissions: identity.permissions.filter((key) => MANAGED_PERMISSION_KEYS.includes(key)),
+ roles: identity.roles,
+ };
+ const response = await handler.fetch(request, {
+ authInfo: {
+ token: caller.token,
+ clientId: caller.clientId,
+ scopes: caller.scopes,
+ expiresAt: caller.expiresAt,
+ resource: new URL(mcpResource),
+ extra: { principal },
+ },
+ });
+ response.headers.set("Cache-Control", "no-store");
+ return response;
+}
+
+export const Route = createFileRoute("/api/mcp")({
+ server: {
+ handlers: {
+ GET: ({ request }) => handle(request),
+ POST: ({ request }) => handle(request),
+ DELETE: ({ request }) => handle(request),
+ },
+ },
+});
diff --git a/src/routes/consent.tsx b/src/routes/consent.tsx
index 8fdde0b..84e4b6b 100644
--- a/src/routes/consent.tsx
+++ b/src/routes/consent.tsx
@@ -2,6 +2,7 @@ import { createFileRoute, useLocation } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import {
BadgeCheck,
+ Bot,
Clock,
KeyRound,
LoaderCircle,
@@ -15,6 +16,7 @@ import { z } from "zod";
import { toast } from "sonner";
import { getConsentApp } from "@/auth/account.functions";
import { authClient } from "@/auth/client";
+import { MCP_SCOPE } from "@/auth/mcp-clients";
import { describeScope } from "@/auth/oidc-clients";
import { useViewer } from "@/components/access";
import { AppHandshake, LoginLayout } from "@/components/login-page";
@@ -90,6 +92,7 @@ const scopeIcons: Record = {
profile: UserRound,
"polinetwork:identity": BadgeCheck,
offline_access: RefreshCw,
+ "idp:mcp": Bot,
};
function Notice({
@@ -209,6 +212,27 @@ function Consent() {
);
}
+ // An agent asking for the MCP server gets nothing from someone who may not use it, so
+ // they decline here instead of approving access the server would refuse.
+ if (viewer && request.scopes.includes(MCP_SCOPE) && !viewer.permissions.includes(MCP_SCOPE)) {
+ return (
+
+ void decide(false)}>
+ {busy === "deny" && }
+ Back to the agent
+
+ }
+ >
+ It needs the {MCP_SCOPE} permission. Ask an administrator.
+
+
+ );
+ }
+
const appName = app?.name ?? "This application";
const scopes = request.scopes.map(describeScope);
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 406d7d8..ddeb14f 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -5,6 +5,7 @@ import type { Viewer } from "@/auth/session.functions";
import { AccountCards } from "@/components/account/account-cards";
import { GroupsPanel, PasskeysPanel, PermissionsPanel } from "@/components/account/access-panels";
import { DebugPanel } from "@/components/account/debug-panel";
+import { McpPanel } from "@/components/account/mcp-panel";
import { Spinner } from "@/components/account/panel";
import { SessionsPanel } from "@/components/account/sessions-panel";
import { useAccountActions } from "@/components/account/use-account-actions";
@@ -65,6 +66,7 @@ function AccountPage({ viewer, details }: { viewer: Viewer; details: AccountDeta
+ {details.mcp && }
From 3897080babd2abf382a40382b7de59bf5a077199 Mon Sep 17 00:00:00 2001
From: Lorenzo Corallo
Date: Sat, 24 Oct 2026 18:19:59 +0200
Subject: [PATCH 2/2] fix(mcp): address review findings
- Agent clients no longer get openid, and a sign-in without a resource gets a
token for the MCP server, so an agent registration cannot read profiles or
sign people in elsewhere. GET and POST authorization requests are rewritten.
- create_application returns the new client's credentials even when the
person cannot read applications; update and delete need read access.
- Agents are pruned after 60 days without use, recorded on their consent,
instead of when they hold no refresh token.
- diagnose_application applies each resource's scope policy.
---
README.md | 6 +--
src/auth/index.ts | 20 ++++----
src/auth/mcp-clients.test.ts | 7 +--
src/auth/mcp-clients.ts | 8 ++--
src/auth/mcp-connections.ts | 34 +++++++++-----
src/auth/mcp-diagnostics.test.ts | 16 ++++++-
src/auth/mcp-diagnostics.ts | 30 ++++++++++--
src/auth/mcp-resource.ts | 23 +++++++--
src/auth/mcp-server.ts | 70 +++++++++++++++++++---------
src/auth/mcp-token.ts | 23 +++++++--
src/components/account/mcp-panel.tsx | 10 +---
11 files changed, 167 insertions(+), 80 deletions(-)
diff --git a/README.md b/README.md
index 4b40ad0..5df1e5f 100644
--- a/README.md
+++ b/README.md
@@ -353,7 +353,7 @@ Your account page shows the server URL and these commands when you hold `idp:mcp
| Gemini CLI | `gemini mcp add --scope user --transport http polinetwork-auth https://auth.polinetwork.org/api/mcp` |
| VS Code | `code --add-mcp '{"name":"polinetwork-auth","type":"http","url":"https://auth.polinetwork.org/api/mcp"}'` |
-Claude Desktop, claude.ai and ChatGPT add it as a custom connector with the same URL. The agent opens a browser, you sign in and approve it once, and it stays connected: it refreshes its access every few minutes, survives signing out of this website, and stops after 30 days without use. Cursor does not work yet: it registers a `cursor://` callback that is not a valid app scheme, so sign-in is refused.
+Claude Desktop, claude.ai and ChatGPT add it as a custom connector with the same URL. The agent opens a browser, you sign in and approve it once, and it stays connected: it refreshes its access every few minutes, survives signing out of this website, and stops after 30 days without use. Cursor does not work yet: it registers a `cursor://` callback that is not a valid app scheme, so sign-in is refused. Agents that run inside a web page, such as the MCP Inspector in direct mode, do not work either: the token endpoint does not answer cross-origin requests.
### What an agent can do
@@ -381,10 +381,10 @@ The server follows the MCP authorization spec. `/.well-known/oauth-protected-res
- A registration must redirect to `http://localhost`, `http://127.0.0.1` or `http://[::1]` (any port), or exactly to a hosted agent's callback (`claude.ai`, `claude.com`, `chatgpt.com`, `vscode.dev`). Someone who tricks you into approving their client cannot collect the code on their own machine.
- It may use only the authorization code grant with refresh tokens, as a public client or with a secret, never with keys. A request with a browser session is refused: applications for people are registered from `/applications`.
-- Its scopes are limited to `idp:mcp`, `offline_access` and `openid`, and it is linked to the MCP server alone, so its tokens are useless anywhere else. Agents that ask for more get only these.
+- Its scopes are limited to `idp:mcp` and `offline_access`, and agents that ask for more get only these. Without `openid` it gets no ID token and cannot call UserInfo. It is linked to the MCP server alone, and a request that names no `resource` gets a token for the MCP server, so its tokens are useless anywhere else.
- Its logo, links and logout addresses are dropped, because PoliNetwork would seem to vouch for them. The consent page shows its name and where it redirects.
-Tokens are EdDSA JWTs addressed to `/api/mcp`, valid five minutes. Every request checks the token, that the agent's client still exists with your consent, and that you still hold `idp:mcp`, so taking the permission away or disconnecting the agent from your account page ends access on the next request. People without `idp:mcp` are stopped on the consent page. Registrations that never got a refresh token are deleted after a day, and so are agents whose last refresh token expired and was cleaned up. Registration is rate-limited to five a minute per address.
+Tokens are EdDSA JWTs addressed to `/api/mcp`, valid five minutes. Every request checks the token, that the agent's client still exists with your consent, and that you still hold `idp:mcp`, so taking the permission away or disconnecting the agent from your account page ends access on the next request. People without `idp:mcp` are stopped on the consent page. Registrations nobody approves within a day are deleted, and so are agents nobody has used for 60 days; each use is recorded on the agent's consent, at most hourly. Registration is rate-limited to five a minute per address.
## Migration work before cutover
diff --git a/src/auth/index.ts b/src/auth/index.ts
index 0bff64f..8381283 100644
--- a/src/auth/index.ts
+++ b/src/auth/index.ts
@@ -26,8 +26,8 @@ import {
import { ensureIdTokenKey, jwtOptions } from "./jwt-options";
import { startKeyPruning } from "./key-pruning";
import { logAuthorizationDenial } from "./denial-log";
-import { MCP_CLIENT_SCOPES, MCP_SCOPE, checkMcpRegistration, narrowMcpScope } from "./mcp-clients";
-import { finishMcpRegistration, mcpClientScopes, mcpResource } from "./mcp-resource";
+import { MCP_CLIENT_SCOPES, MCP_SCOPE, checkMcpRegistration } from "./mcp-clients";
+import { finishMcpRegistration, mcpAuthorizationRequest, mcpResource } from "./mcp-resource";
import { hasIdpPermission } from "./idp-access";
import { canAdministerIdp } from "./oidc-admin";
import { validateClientJwks } from "./client-jwks";
@@ -143,16 +143,12 @@ export const auth = betterAuth({
},
};
}
- // An agent that asks for more than it may have gets what it may have, not an error.
- if (
- context.path === "/oauth2/authorize" &&
- typeof context.query?.scope === "string" &&
- typeof context.query.client_id === "string" &&
- context.query.scope.split(" ").includes(MCP_SCOPE)
- ) {
- const allowed = await mcpClientScopes(context.query.client_id);
- const scope = allowed && narrowMcpScope(context.query.scope, allowed);
- if (scope) return { context: { query: { ...context.query, scope } } };
+ // Agents' sign-ins always end in a token for the MCP server and nothing more.
+ if (context.path === "/oauth2/authorize") {
+ const fromBody = context.method === "POST";
+ const params = fromBody ? context.body : context.query;
+ const rewritten = params && (await mcpAuthorizationRequest(params));
+ if (rewritten) return { context: fromBody ? { body: rewritten } : { query: rewritten } };
}
if (refusedClientMutationPaths.includes(context.path))
throw new APIError("FORBIDDEN", {
diff --git a/src/auth/mcp-clients.test.ts b/src/auth/mcp-clients.test.ts
index 70d3a4a..d2de33f 100644
--- a/src/auth/mcp-clients.test.ts
+++ b/src/auth/mcp-clients.test.ts
@@ -38,7 +38,7 @@ describe("MCP agent registration", () => {
problem: null,
applicationType: "native",
// Agents ask for every scope the server lists; they get the ones they may have.
- scope: "openid offline_access idp:mcp",
+ scope: "offline_access idp:mcp",
});
});
@@ -101,10 +101,11 @@ describe("MCP agent registration", () => {
describe("MCP scopes", () => {
it("keeps only what an agent may have", () => {
expect(narrowMcpScope("openid profile idp:mcp offline_access", MCP_CLIENT_SCOPES)).toBe(
- "openid idp:mcp offline_access",
+ "idp:mcp offline_access",
);
expect(narrowMcpScope("idp:mcp idp:mcp", MCP_CLIENT_SCOPES)).toBe("idp:mcp");
- expect(narrowMcpScope("profile email", MCP_CLIENT_SCOPES)).toBeNull();
+ // Without openid an agent gets no ID token and cannot read UserInfo.
+ expect(narrowMcpScope("openid profile email", MCP_CLIENT_SCOPES)).toBeNull();
expect(narrowMcpScope(undefined, MCP_CLIENT_SCOPES)).toBeNull();
});
diff --git a/src/auth/mcp-clients.ts b/src/auth/mcp-clients.ts
index 9cd651f..723bd5f 100644
--- a/src/auth/mcp-clients.ts
+++ b/src/auth/mcp-clients.ts
@@ -7,11 +7,11 @@ export const MCP_PATH = "/api/mcp";
export const MCP_SCOPE = "idp:mcp";
/**
- * Everything a self-registered MCP client may ask for. `openid` is there for clients that
- * add it out of habit and only reveals the user ID; `offline_access` lets them refresh.
- * Profile, email and identity claims are never offered to these clients.
+ * Everything a self-registered MCP client may ask for: `offline_access` lets it refresh.
+ * Without `openid` it gets no ID token and cannot call UserInfo, so anonymous registration
+ * never turns into a way to sign people in elsewhere or read their profile.
*/
-export const MCP_CLIENT_SCOPES = ["openid", "offline_access", MCP_SCOPE];
+export const MCP_CLIENT_SCOPES = ["offline_access", MCP_SCOPE];
/**
* Callback pages of hosted agents, matched exactly. A self-registered client may otherwise
diff --git a/src/auth/mcp-connections.ts b/src/auth/mcp-connections.ts
index 82864ed..99d64d8 100644
--- a/src/auth/mcp-connections.ts
+++ b/src/auth/mcp-connections.ts
@@ -1,4 +1,4 @@
-import { and, count, desc, eq, lt, notExists, sql } from "drizzle-orm";
+import { and, count, desc, eq, gt, lt, notExists, sql } from "drizzle-orm";
import { db } from "../db/index";
import { oauthAccessToken, oauthClient, oauthConsent, oauthRefreshToken } from "../db/schema";
import { isMcpClient } from "./mcp-resource";
@@ -9,8 +9,8 @@ export type McpConnection = {
/** Where the agent receives its sign-in, such as `127.0.0.1:53682` or `claude.ai`. */
redirectHost: string | null;
connectedAt: Date;
- /** When the agent last refreshed its access, which it does every few minutes in use. */
- lastUsedAt: Date | null;
+ /** When the agent last used the server, to the hour; see `verifyMcpToken`. */
+ lastUsedAt: Date;
};
function hostOf(uri: string | undefined) {
@@ -29,9 +29,7 @@ export async function listMcpConnections(userId: string): Promise {
});
describe("authorization request check", () => {
- const context = { resources, authorizeEndpoint };
+ const internal = "https://auth.polinetwork.org/api/internal";
+ const context = {
+ authorizeEndpoint,
+ resourcePolicies: {
+ [resources.backend]: { allowedScopes: null, disabled: false },
+ [internal]: { allowedScopes: ["idp:access:read"], disabled: false },
+ },
+ };
it("accepts a request the provider would accept", () => {
const findings = checkAuthorizationRequest(client(), authorize({}), context);
@@ -125,6 +132,13 @@ describe("authorization request check", () => {
expect(findings.join("\n")).toMatch(/not linked to resource/);
});
+ it("applies the scope policy of a requested resource", () => {
+ const linked = client({ resourceIds: [internal], scopes: ["openid", "profile"] });
+ const refused = checkAuthorizationRequest(linked, authorize({ resource: internal }), context);
+ expect(messages(refused)).toEqual([expect.stringMatching(/invalid_scope.*idp:access:read/)]);
+ expect(messages(refused, "info").join("\n")).not.toMatch(/would accept/);
+ });
+
it("reads a bare query string, and a request without PKCE", () => {
const query = new URL(authorize({})).search;
const findings = checkAuthorizationRequest(
diff --git a/src/auth/mcp-diagnostics.ts b/src/auth/mcp-diagnostics.ts
index 9923319..6831494 100644
--- a/src/auth/mcp-diagnostics.ts
+++ b/src/auth/mcp-diagnostics.ts
@@ -205,10 +205,17 @@ export function diagnoseClient(client: OidcClientSummary, context: DiagnosisCont
* Checks an authorization request the way `/oauth2/authorize` would, in the same order, and
* says what the client would get back. `request` is a full authorization URL or its query.
*/
+/** A protected resource's token policy, as the provider stores it. */
+export type ResourcePolicy = { allowedScopes: string[] | null; disabled: boolean };
+
export function checkAuthorizationRequest(
client: OidcClientSummary,
request: string,
- context: DiagnosisContext & { authorizeEndpoint: string },
+ context: {
+ authorizeEndpoint: string;
+ /** Every resource the provider knows, by identifier. */
+ resourcePolicies: Record;
+ },
): Finding[] {
const { list, error, warning, info } = findings();
let params: URLSearchParams;
@@ -259,13 +266,26 @@ export function checkAuthorizationRequest(
if (value("claims") !== undefined && !scopes.includes("openid"))
error("The claims parameter needs the openid scope (`invalid_request`).");
- const resources = params.getAll("resource");
- const configured = Object.values(context.resources);
+ const resources = [...new Set(params.getAll("resource"))];
+ // The token carries only the scopes every requested resource allows, and none is refused.
+ let granted = scopes;
for (const resource of resources) {
- if (!configured.includes(resource))
- error(`resource ${resource} is not configured here (\`invalid_target\`).`);
+ const policy = context.resourcePolicies[resource];
+ if (!policy) error(`resource ${resource} is not configured here (\`invalid_target\`).`);
+ else if (policy.disabled) error(`resource ${resource} is disabled (\`invalid_target\`).`);
else if (!client.resourceIds.includes(resource))
error(`The application is not linked to resource ${resource} (\`invalid_target\`).`);
+ else if (policy.allowedScopes) {
+ const allowed = policy.allowedScopes;
+ const kept = granted.filter((entry) => allowed.includes(entry));
+ if (!kept.length)
+ error(
+ `None of the requested scopes are allowed for resource ${resource} (\`invalid_scope\`); it accepts ${allowed.join(" ")}.`,
+ );
+ else if (kept.length < granted.length)
+ info(`The token for ${resource} carries only: ${kept.join(" ")}.`);
+ granted = kept;
+ }
}
if (!resources.length && scopes.some(isApiScope))
warning(
diff --git a/src/auth/mcp-resource.ts b/src/auth/mcp-resource.ts
index edbb0b6..a24b206 100644
--- a/src/auth/mcp-resource.ts
+++ b/src/auth/mcp-resource.ts
@@ -2,7 +2,7 @@ import { and, eq, isNull } from "drizzle-orm";
import { db } from "../db/index";
import { oauthClient, oauthClientResource } from "../db/schema";
import { env } from "../env";
-import { MCP_PATH } from "./mcp-clients";
+import { MCP_CLIENT_SCOPES, MCP_PATH, narrowMcpScope } from "./mcp-clients";
/** The MCP server's resource identifier: the URL agents connect to, and their tokens' audience. */
export const mcpResource = new URL(MCP_PATH, env.BETTER_AUTH_URL).href;
@@ -54,11 +54,24 @@ export async function finishMcpRegistration(clientId: string) {
});
}
-/** The scopes an agent's client may ask for, or null when the client is not an agent's. */
-export async function mcpClientScopes(clientId: string) {
+/**
+ * An authorization request from an agent's client, rewritten to get a token for the MCP
+ * server: scopes it may not have are dropped (RFC 6749 §3.3 lets the server grant fewer
+ * than asked), and a missing `resource` is the MCP server, so its token is never one other
+ * services or UserInfo accept. Returns null for every other client, and for a request the
+ * provider must refuse as it is.
+ */
+export async function mcpAuthorizationRequest(params: Record) {
+ if (typeof params.client_id !== "string" || params.request || params.request_uri) return null;
const [client] = await db
.select({ scopes: oauthClient.scopes })
.from(oauthClient)
- .where(and(eq(oauthClient.clientId, clientId), isMcpClient));
- return client ? (client.scopes ?? []) : null;
+ .where(and(eq(oauthClient.clientId, params.client_id), isMcpClient));
+ if (!client) return null;
+ // Registrations from before a scope was withdrawn keep it stored; it is not granted.
+ const allowed = (client.scopes ?? []).filter((scope) => MCP_CLIENT_SCOPES.includes(scope));
+ // Without a scope the provider would grant the stored ones, so name the allowed ones.
+ const scope = narrowMcpScope(params.scope ?? allowed.join(" "), allowed);
+ if (!scope) return null;
+ return { ...params, scope, ...(params.resource === undefined ? { resource: mcpResource } : {}) };
}
diff --git a/src/auth/mcp-server.ts b/src/auth/mcp-server.ts
index 536c729..7db3883 100644
--- a/src/auth/mcp-server.ts
+++ b/src/auth/mcp-server.ts
@@ -4,7 +4,7 @@ import { createLocalJWKSet, decodeJwt, decodeProtectedHeader, jwtVerify, type JW
import { z } from "zod";
import { ActionError } from "@/lib/action-error";
import { db } from "../db/index";
-import { rbacAuditEvent, user } from "../db/schema";
+import { oauthResource, rbacAuditEvent, user } from "../db/schema";
import { toActionError } from "./guard";
import { getIdentity } from "./identity";
import { auth, tokenLifetimes } from "./index";
@@ -163,6 +163,23 @@ function diagnosisContext(): DiagnosisContext {
return { resources: configuredResources() };
}
+/** The token policy of every resource, which the provider seeds from its configuration. */
+async function resourcePolicies() {
+ const rows = await db
+ .select({
+ identifier: oauthResource.identifier,
+ allowedScopes: oauthResource.allowedScopes,
+ disabled: oauthResource.disabled,
+ })
+ .from(oauthResource);
+ return Object.fromEntries(
+ rows.map((row) => [
+ row.identifier,
+ { allowedScopes: row.allowedScopes, disabled: row.disabled ?? false },
+ ]),
+ );
+}
+
function compactClient(client: OidcClientSummary) {
return {
clientId: client.clientId,
@@ -407,7 +424,7 @@ export function createMcpServer(principal: McpPrincipal) {
...(authorizationUrl
? {
authorizationRequest: checkAuthorizationRequest(client, authorizationUrl, {
- ...context,
+ resourcePolicies: await resourcePolicies(),
authorizeEndpoint: `${issuer}/oauth2/authorize`,
}),
}
@@ -430,16 +447,42 @@ export function createMcpServer(principal: McpPrincipal) {
respond(async () => {
const draft = checkClientDraft({ ...emptyClientDraft(), redirectUris: [], ...input });
const created = await createOidcClient(actor, draft);
- const [client] = await listOidcClients(actor, created.clientId);
+ // The application exists and its secret is shown only now, so failing to read it
+ // back, for someone who may register applications without seeing them, must not
+ // lose the credentials.
+ const [client] = can("idp:applications:read")
+ ? await listOidcClients(actor, created.clientId).catch(() => [])
+ : [];
return {
clientId: created.clientId,
clientSecret: created.clientSecret,
- application: client,
- findings: client ? diagnoseClient(client, diagnosisContext()) : [],
+ ...(client
+ ? { application: client, findings: diagnoseClient(client, diagnosisContext()) }
+ : {}),
};
}),
);
+ server.registerTool(
+ offer("rotate_application_secret"),
+ {
+ title: "Rotate a client secret",
+ description:
+ "Replaces an application's client secret. The old one stops working at once; the new one is returned once.",
+ inputSchema: z.object({ clientId: z.string().min(1) }),
+ annotations: { destructiveHint: true },
+ },
+ ({ clientId }) =>
+ respond(async () => {
+ const result = await rotateOidcClientSecret(actor, clientId);
+ if (!result) throw new RbacError(404, "Application not found.");
+ return result;
+ }),
+ );
+ }
+
+ // Changing or deleting an application starts from reading it.
+ if (can("idp:applications:write", "idp:applications:read")) {
server.registerTool(
offer("update_application"),
{
@@ -471,23 +514,6 @@ export function createMcpServer(principal: McpPrincipal) {
}),
);
- server.registerTool(
- offer("rotate_application_secret"),
- {
- title: "Rotate a client secret",
- description:
- "Replaces an application's client secret. The old one stops working at once; the new one is returned once.",
- inputSchema: z.object({ clientId: z.string().min(1) }),
- annotations: { destructiveHint: true },
- },
- ({ clientId }) =>
- respond(async () => {
- const result = await rotateOidcClientSecret(actor, clientId);
- if (!result) throw new RbacError(404, "Application not found.");
- return result;
- }),
- );
-
server.registerTool(
offer("delete_application"),
{
diff --git a/src/auth/mcp-token.ts b/src/auth/mcp-token.ts
index 1e1ccaf..292f86c 100644
--- a/src/auth/mcp-token.ts
+++ b/src/auth/mcp-token.ts
@@ -1,4 +1,4 @@
-import { and, eq } from "drizzle-orm";
+import { and, desc, eq } from "drizzle-orm";
import { createLocalJWKSet, jwtVerify, type JWK } from "jose";
import { db } from "../db/index";
import { oauthClient, oauthConsent } from "../db/schema";
@@ -6,6 +6,8 @@ import { auth } from "./index";
import { MCP_SCOPE } from "./mcp-clients";
import { isMcpClient, issuer, mcpResource } from "./mcp-resource";
+const USE_RECORD_INTERVAL_MS = 60 * 60 * 1000;
+
export type McpCaller = {
token: string;
userId: string;
@@ -61,14 +63,27 @@ export async function verifyMcpToken(authorization: string | null): Promise{name}
{agent.redirectHost && <>{agent.redirectHost} · >}
- {agent.lastUsedAt ? (
- <>
- Active
- >
- ) : (
- <>
- Connected
- >
- )}
+ Active