diff --git a/.env.template b/.env.template index da18c853cd..b66048cf68 100644 --- a/.env.template +++ b/.env.template @@ -81,3 +81,21 @@ CHROMATIC_PROJECT_TOKEN=chpt_sample_secret # THEME OPS_DARK_THEME_ENABLED=false OPS_CODE_BLOCK_MEMORY_LIMIT_IN_MB=256 + +# EXTERNAL AGENT OAUTH (OPS-4673) +# Turns on the OAuth 2.1 authorization server used by external agents. +OPS_OAUTH_ENABLED=false +# Public base URL of this API. Becomes the token issuer and the API audience. +OPS_OAUTH_ISSUER_URL=http://localhost:3000 +# Canonical URL of the hosted MCP server, when one is deployed. +OPS_MCP_RESOURCE_URL= +# Shared secret the MCP resource server authenticates with. Minimum 32 characters. +OPS_OAUTH_RS_CLIENT_SECRET= +# Token lifetimes. Shown with their defaults; the access-token TTL is the upper +# bound on how long a revoked connection can keep working. +OPS_OAUTH_ACCESS_TOKEN_TTL_SECONDS=900 +OPS_OAUTH_REFRESH_TOKEN_TTL_DAYS=30 +OPS_OAUTH_EXCHANGE_TOKEN_TTL_SECONDS=300 +# Optional: sign OAuth tokens with an operator-managed key instead of the +# auto-generated one held in the database. +OPS_OAUTH_SIGNING_KEY_PEM_PATH= diff --git a/.gitignore b/.gitignore index a30eebbccd..f5010a7e13 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ node_modules /tmp /.nx /cache +# The engine resolves its code cache relative to the working directory, so running a +# server from inside its own package writes one there too. Needs `**/` because a pattern +# with an interior slash is anchored to this file's directory. +**/cache/codes/ /packages/ui-components/storybook-static diff --git a/docs/oauth-design.md b/docs/oauth-design.md new file mode 100644 index 0000000000..b00ae5db4c --- /dev/null +++ b/docs/oauth-design.md @@ -0,0 +1,608 @@ +# External Agent OAuth — Design + +**Date:** 2026-07-27 +**Status:** Approved +**Linear:** Fixes OPS-4673 +**Supersedes:** the `feat/mcp-oauth-authentication` spike in `openops-internal` and its +three specs (2026-07-20 base, 2026-07-21 hardening, 2026-07-21 generalization). This is +a fresh design informed by an adversarial security audit of that spike. + +## Problem + +OpenOps ships a Python **FastMCP** server (`mcp-server/`) that exposes a filtered set of +OpenOps API routes as MCP tools. Today it authenticates with a single static +`AUTH_TOKEN` env var used as a `Bearer` JWT on every API call. That works for the +built-in AI chat (the Node API spawns it over stdio and injects a short-lived `SERVICE` +JWT) but not for **external agents** — Claude Code, Codex, Claude.ai/ChatGPT connectors, +M365 Copilot, partner CLIs — which need a self-service, revocable credential that works +when SSO is enabled and password login is disabled (OPS-4673). + +## Requirements (locked) + +1. **Clients:** all of — M365 Copilot (strictest: OAuth 2.1 + DCR + Streamable HTTP, + valid discovery, no API keys), Claude.ai/ChatGPT web connectors, dev CLIs + (Claude Code/Codex, loopback redirects), and custom/partner agents calling the + **REST API directly** with an OAuth token (no MCP in between). +2. **Deployments:** cloud **and** self-hosted → the authorization server ships inside + the OpenOps product (Node API) and delegates login to whatever auth the deployment + uses. No dependency on Frontegg or any external IdP. +3. **Topology:** one MCP server co-deployed per OpenOps instance (path-routed on the + same public host). Not multi-tenant. +4. **Connections:** a user may hold **several independent connections**, including + more than one for the same agent. Each is authorized, listed and revoked on its + own. Single full-access scope per resource in v1 (`mcp`, `api`). +5. **Projects:** every OAuth-issued token carries a required `project_id` claim and + acts only on that project, so an individual token's authority is fixed for its whole + life and cannot be redirected by changing stored state. A _connection_ is not fixed: + it can act wherever its user can, by asking for a different project when it gets a + token. That mirrors how enterprise's `POST /v1/authentication/switch-project` issues + a new token per project rather than mutating state — the switch produces a new + credential, never a rewritten one. The bound is the user's own membership, re-read on + every mint and again on every request, so a connection can never reach further than + its user could in the browser. This edition has one project per organization, so + there is usually nowhere else to go; the mechanism is the same either way. +6. **Revocation is a hard requirement:** users/admins revoke a connection and it stops + working promptly. + +## Standards targeted + +- **OAuth 2.1** (PKCE mandatory, refresh rotation, exact redirect matching). +- **MCP Authorization spec 2025-11-25**: RFC 9728 Protected Resource Metadata + + `WWW-Authenticate`; RFC 8414 AS metadata **and** OIDC-Discovery-compatible document; + RFC 8707 resource indicators; DCR (RFC 7591) now, CIMD (SEP-991) as a follow-up. +- **RFC 8693** token exchange (RS → API-audience tokens; no token passthrough). +- **RFC 7009** revocation; **RFC 9207** `iss` authorization-response parameter. +- Honest metadata only: nothing advertised that isn't actually served. + +## Audit findings this design must fix (from the spike review) + +| ID | Finding | Fix in this design | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| H1 | Refresh-token reuse undetected | Token **families**; reuse revokes the family | +| H2 | Grant revocation didn't revoke refresh tokens | Revocation cascades via indexed `grantId` | +| H3 | Consent forgeable from URL params (one-click account grant) | Server-side **pending-authorization record**; consent references an opaque `request_id`; client metadata rendered from DB only | +| H4 | Open redirect on Deny | Deny goes through the server; redirect validated against registered URIs | +| M1 | Code/refresh consumption race (read-then-write) | Atomic conditional `UPDATE … WHERE consumedAt IS NULL` | +| M2 | Static form-field exchange secret, `change-me` default, unrate-limited | RS is a **confidential client** with a generated high-entropy secret (hashed at rest), `client_secret_basic`, rate-limited failures | +| M3 | Audience deny-list in one handler; websockets bypass | **Positive** audience enforcement inside `extractPrincipal` (single chokepoint) | +| M4 | One HS256 secret signs everything; fake `jwks_uri` | Dedicated **RS256 keypair + real JWKS** for OAuth tokens | +| M5 | In-process `_active_project_by_user` map (cross-session leakage, restart loss) | No mutable server-side project state: the project is a claim on each token, and switching mints a new token rather than editing one | +| M6 | 2× remote exchange per tool call; proceeds unauthenticated on failure | Local JWKS validation; exchange only to mint API tokens, cached, **fail-closed** | +| M7 | Non-RFC 6749 error bodies | Dedicated OAuth error serializer for `/v1/oauth/*` | +| M8 | Phantom grants at consent | Grant created at code redemption, not at consent (repeat authorizations are intentionally separate connections) | +| L1–L6 | DCR validation gaps, cleanup gaps, migration nits, lying metadata, cookie-over-bearer precedence, `/switch-project` minting primitive | Addressed in the relevant sections below | + +## Architecture + +### Roles + +- **Node API (Fastify)** — OAuth 2.1 **Authorization Server** (new module + `packages/server/api/src/app/oauth/`) _and_ a protected resource: the `direct` token + model lets CLIs call the REST API with an OAuth token (`aud = api`). Login/consent + ride the existing app session, so SSO and password deployments both work. +- **Python FastMCP server (`mcp-server/`)** — MCP **Resource Server** over Streamable + HTTP. Validates inbound bearers **locally** via the AS JWKS (FastMCP `JWTVerifier` + + `RemoteAuthProvider`). Never forwards the client token: per tool call it exchanges it + (RFC 8693) for a separate short-lived API-audience token, cached, fail-closed. +- **Resource registry** (static config in the AS): `mcp` (canonical URI = public MCP + URL, token model `exchange`) and `api` (canonical URI = API URL, token model + `direct`). `resource` on `/authorize` and `/token` is validated against it + (`invalid_target` otherwise) and binds the token `aud`. + +### End-to-end flow + +1. Client → `https:///mcp` unauthenticated → `401` + + `WWW-Authenticate: Bearer resource_metadata="…"`. +2. Client fetches `/.well-known/oauth-protected-resource[/mcp]` (served by RS) → learns + the AS issuer. +3. Client fetches AS metadata (RFC 8414 and/or OIDC discovery), registers via DCR, + opens `/oauth/authorize` with PKCE (S256) + `state` + `resource`. +4. AS validates everything, persists a **pending-authorization record**, sends the + browser to Settings → Connected apps with only an opaque `request_id`. + Unauthenticated users go through normal app login (SSO-aware) first. +5. The consent dialog fetches client metadata **from the server by `request_id`** (never + from URL params), user approves/denies. Approve → single-use code bound to the + record; deny → server-validated `error=access_denied` redirect. Dismissing the dialog + denies, so a client is never left waiting on a decision the user has walked away + from. Both redirects carry `state` and `iss` (RFC 9207). +6. Client exchanges code at `/oauth/token` (PKCE verifier + `resource`) → RS256 access + token (`aud` = resource) + rotating refresh token. Grant activated/upserted here. +7. MCP calls: RS validates locally via JWKS (issuer + audience + exp), exchanges for an + API-audience token (cached ≈60s, fail-closed), calls the API. Direct clients skip + the RS and hit the API with their `aud=api` token. +8. API-side: `extractPrincipal` verifies signature by `kid`, enforces `aud=api` + positively, maps claims → `SERVICE` principal with the user's **real project role**, + and checks grant status (cached ≈60s) → revocation cuts access in ~1 minute. + +## Tokens & keys + +### Why a dedicated asymmetric keypair + +Today every JWT (sessions, worker ~100y tokens, engine, AI-chat) is HS256 under the one +`OPS_JWT_SECRET`. Symmetric signing means whoever can _verify_ can also _mint_ — so the +verification key can never be shared with the Python RS (forcing the spike into remote +validation per request), and a single leak forges every principal type. + +OAuth-issued tokens are therefore signed with a **dedicated RS256 keypair**: + +- The **public key** is published at a real `GET /.well-known/jwks.json`; the RS (and + any future resource server) validates tokens locally, in-process. An AS blip no + longer takes down MCP traffic. +- RS256 over EdDSA purely for client compatibility (M365, Python/Node stacks all verify + RS256 out of the box). Ed25519 is a documented follow-up. +- **Two isolated trust domains:** the internal HS256 world is untouched (zero + regression on workers/engine/sessions); compromising the OAuth key forges only + OAuth tokens — which remain subject to the per-request grant-status check, so the + damage is revocable. Compromising `OPS_JWT_SECRET` no longer exposes external-agent + auth and vice versa. + +### Key management + +- **Bootstrap:** on first boot the API generates an RSA-2048 keypair, encrypts the + private key with the existing AES-256-CBC mechanism (`encrypt-compress.ts`, same + protection level as app-connection credentials), stores it in `oauth_signing_key`, + serves the public half in the JWKS. Zero new config for self-hosted; multi-instance + replicas share the key via the DB (creation is guarded by a unique active-key + constraint so concurrent boots converge). +- **Override:** optional system prop pointing at an operator-provided PEM (Vault/KMS + users) — the DB path is a default, not a cage. +- **Rotation (`kid`-based):** generate key #2, publish both in JWKS, sign new tokens + with #2, drop #1 from JWKS after every #1-signed token has expired (access TTL is + 15 min, so the horizon is short). Admin-triggerable; also the recovery path for a + suspected key compromise. Every OAuth JWT header carries its `kid`; + `extractPrincipal` dispatches on it (legacy internal `kid: '1'` → HS256 path). + +### Token shapes + +| Token | Form | TTL (default, configurable) | Notes | +| ------------------- | ------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Authorization code | 32B CSPRNG, SHA-256 hash stored | 60 s, single-use (atomic consume) | Bound to client, redirect_uri, PKCE challenge, resource, user, pending request | +| Access token | RS256 JWT | **15 min** | Claims: `iss`, `sub` (userId), `aud` (resource audience), `exp`, `iat`, `jti`, `client_id`, `scope`, `grant_id`, `project_id` | +| Refresh token | 32B CSPRNG, SHA-256 hash stored | 30 d absolute; rotates on use | Carries `grantId` + `familyId` (both indexed) | +| Exchanged API token | RS256 JWT, `aud = api` | ~5 min | Minted at token-exchange for the grant's active project; never returned to end clients | + +Opaque secrets are never stored in plaintext; comparisons are hash-lookup or +timing-safe. All token responses set `Cache-Control: no-store`. + +## Authorization server surface + +All under `/v1/oauth/*` + well-known routes, registered **only when +`OPS_OAUTH_ENABLED=true`**, as public routes in the security chain (each endpoint does +its own auth), with a dedicated **RFC 6749 error serializer** (`{"error": +"invalid_grant", "error_description": …}`, correct 400/401 statuses) instead of the +ApplicationError envelope. + +- `GET /.well-known/oauth-authorization-server` and + `GET /.well-known/openid-configuration` — same truthful document: issuer, endpoints, + `code` response type, `authorization_code`/`refresh_token` grants, S256, + `token_endpoint_auth_methods_supported: ["none","client_secret_basic"]`, real + `jwks_uri`, scopes. **No** fake id-token fields. Served from the issuer origin only + (the spike's RS-origin copy with mismatched issuer is dropped — strict RFC 8414 + clients reject it). +- `GET /.well-known/jwks.json` — active + retiring public keys. +- `POST /oauth/register` (DCR, public): validates and bounds every field + (`redirect_uris` ≤ 10, https or loopback only, length caps, `grant_types` whitelist — + **enforced later at `/token`**, L1), returns RFC 7591 bodies/errors. Rate-limited + per-IP (existing rate-limit module). Registered clients are `token_endpoint_auth_method: +none` (public, PKCE-only). +- `GET /oauth/authorize` — requires a logged-in app session (redirects into normal + login, SSO-aware, then back). Validates client, **exact** redirect_uri (https or + loopback; loopback matches any port per RFC 8252), PKCE S256-only, known `resource`, + scope ⊆ resource scopes. On unknown client/unregistered redirect_uri: render an error + page, **never redirect**. On success: persist `oauth_pending_authorization` + (~10 min TTL, single-use) and redirect the browser to Settings → Connected apps with + only `?request_id=`. +- `GET /oauth/requests/{id}` (USER session) — consent-page data: client name + from the **DB**, scopes, resource id. Any signed-in user holding the (unguessable) + request id can read it: the record is not bound to a user until the decision is + submitted. +- `POST /oauth/requests/{id}/decision` (USER session) — `{approve: boolean}`. + Atomically consumes the pending record (its single-use consumption is the CSRF/replay + barrier; the session cookie is `sameSite: lax`, and the route additionally requires a + custom header to defeat form-post CSRF). Approve → upsert grant (see below), issue + code, return the validated redirect URL (`code`, `state`, `iss`). Deny → + `error=access_denied` redirect URL, equally validated. The frontend only ever + navigates to server-returned URLs (fixes H3 + H4). +- `POST /oauth/token` (public, rate-limited with failure-weighted limits): + - `authorization_code` — atomic single-use consume; verify PKCE (timing-safe), + client, redirect_uri, resource; enforce the client's registered `grant_types`; + mint access + refresh (new `familyId`), activate the grant. + - `refresh_token` — atomic rotate; **reuse of a rotated/revoked token revokes the + entire family** (H1) and logs a security event; checks grant active + user active + on every rotation (H2); re-binds `resource`. Accepts an optional `project_id` to + move the connection, checked against membership **before** the token is consumed, so + a refused switch does not cost a working credential. + - `urn:ietf:params:oauth:grant-type:token-exchange` — **RS-only**: authenticated via + `client_secret_basic` with the RS's confidential client (secret generated at + provisioning, stored hashed, timing-safe compare, rate-limited failures — M2). + Validates the subject token (signature, `aud = mcp`, exp), checks grant active + + user active + membership of the target project, mints the ~5 min `aud=api` token. + The target defaults to the project the subject token names; the RS may pass + `project_id` to act elsewhere, which is how an agent switches project. It cannot + widen beyond the user's own membership, which is re-read here on every exchange. +- `POST /oauth/revoke` (RFC 7009, public with client identification): revokes by + refresh token → marks grant + family revoked. +- `GET /oauth/grants` / `DELETE /oauth/grants/{id}` (USER, project-scoped policy): + connected-apps management. Delete = revoke grant **and cascade-revoke all its refresh + tokens** (indexed `grantId` UPDATE — H2). +- `GET /oauth/projects` (USER **or SERVICE**): the projects the caller may act in, and + which one they are acting in now. `SERVICE` is allowed because this is the one route a + connection calls about itself, to find out where it can switch to; it exposes only the + names of projects the caller already reaches. + +### Grant model + +`oauth_grant` — one row per **connection**: one completed authorization for one +client and user. Created at code redemption (**not** at consent, so an +authorization the client never finished is not shown as a connection): +`id`, `clientId`, `userId`, `resourceId`, +`status (active|revoked)`, `createdAt`, `lastUsedAt`, `revokedAt`. + +The index on `(clientId, userId)` is deliberately **not unique**. Authorizing the +same agent again creates another connection rather than mutating the first, so a +user can run several agents — or several installs of one agent — side by side and +revoke any one of them without disturbing the others. `projectId` is fixed at +authorization time and never mutated (see requirement 5). + +Because reconnecting accumulates rows, the cleanup job removes **dead** +connections: those with no unrevoked refresh token left and unused for 30 days. A +connection with any usable refresh token is never touched. + +Revocation semantics, per connection: revoked grant → token exchange refuses (MCP +cutoff), the API's grant-status check refuses (direct cutoff), and refresh +refuses, so no new tokens can be minted. Access-token TTL (15 min) is the absolute +worst case, and other connections are unaffected. + +### Project authorization + +The project a token may act on is a **required `project_id` claim**, minted by the +authorization server and never asserted by the client. Each individual token is +immutable — the project it names is fixed for its whole life, so a leaked token's blast +radius is fixed with it. + +The claim is a _selector, not a grant of authority_. Every request that presents an +OAuth token re-authorizes the named project, so withdrawing someone's access takes +effect at their next request rather than at token expiry. + +**Switching project.** Because the claim is a selector, a connection is not confined to +one project — it acts wherever the user can, exactly as their browser session does. A +client asks for a different project when getting a token, and membership decides: + +- `POST /token` with `grant_type=refresh_token` and `project_id` — how a direct API + client (CLI, partner agent) moves. +- `POST /token` with the token-exchange grant and `project_id` — how a resource server + moves on an agent's behalf. This is the path an MCP client such as Claude Code takes, + since it cannot mint tokens itself. +- `GET /v1/oauth/projects` — where a connection may go, and where it is now. Allows + `SERVICE` so the connection itself can ask. + +A project the user is not a member of is refused with `invalid_target` (RFC 8707), and +on the refresh path the refusal happens **before** the token is consumed, so asking for +the wrong project does not cost a working credential. Nothing is stored: a switch lasts +exactly as long as the token it produced, and `oauth_grant.projectId` continues to +record where the connection started rather than where it is. + +The security property is that a switch can never reach further than the user can. The +bound is their own membership, re-read on every mint and again on every request. + +The three questions the server asks about projects sit behind one factory, +`getOAuthProjectMembershipService()` — following the convention used by +`authentication-service-factory` and friends, where an edition overrides behaviour +by swapping the import in the factory file: + +- `getDefaultForUser(user)` — where a newly authorized connection starts. +- `getForUser(user, projectId)` — whether the user may act there, and as what role. +- `listForUser(user)` — every project the connection may switch to. + +`listForUser` must stay consistent with `getForUser`: if it returned less than +`getForUser` permits, a client would be shown one destination while the token endpoint +allowed another it was never told about. This edition answers all three from the +organization's projects with role `ADMIN`, matching the session login path. An edition +with real project membership maps them onto its own lookups (in the enterprise fork, +`usersService.getLandingProjectForUser` and `usersService.getUserProject`, which +already return `{ project, projectRole }`) and gets real per-project roles and +multi-project switching with no change to the OAuth code. `projectRole` is deliberately +typed as `string` here because the role enum lives in enterprise-only shared code. + +**The project lives on the refresh token, not the grant.** It was on the grant first, +used as the default when a refresh named no project — which meant a plain renewal put the +connection back where it started, silently discarding a switch. An agent would have moved +to another project and drifted back roughly 15 minutes later, with nothing to attribute it +to. The refresh token is the credential chain, so it is what carries the current project: +rotation copies it forward unless the client asks to move, and renewing a credential +therefore yields an equivalent one. That also left the grant's copy unread, so it is +gone — `test/unit/oauth/tokens.service.test.ts` pins the behaviour. + +### Data model (new tables) + +- `oauth_signing_key` — `id` (kid), `privateKeyEncrypted`, `publicKeyPem`, + `status (active|retiring|retired)`, timestamps. A partial unique index over + `status = 'active'` is what makes concurrent replica boots converge on one key. +- `oauth_client` — DCR clients + the provisioned RS confidential client: + `id`, `clientName`, `redirectUris` (jsonb), `grantTypes` (jsonb), + `tokenEndpointAuthMethod`, `clientSecretHash` (nullable), timestamps. + Usage is recorded per connection on the grant, not per client. No `scope`: a client may + send one at registration, but what a token gets is decided by the resource it names, so + storing the request would be a second answer nothing reads. +- `oauth_pending_authorization` — `id` (opaque request_id), `clientId` (FK), + `redirectUri`, `codeChallenge`, `resource`, `scope`, `state`, `expiresAt`, + `consumedAt`. No `userId`: the acting user is not known until the decision is + submitted (see deviation 2). +- `oauth_authorization_code` — `codeHash` (unique), `clientId` (FK), `userId`, + `redirectUri`, `codeChallenge`, `resource`, `scope`, `expiresAt`, `consumedAt`. +- `oauth_refresh_token` — `tokenHash` (unique), `grantId` (FK, **indexed**), + `familyId` (**indexed**), `clientId`, `resource`, `scope`, `projectId`, `expiresAt` + (**indexed**), `revokedAt`. No `userId`: the grant records the acting user and is + authoritative, so a copy here could only ever disagree. `projectId` **is** here rather + than on the grant: it is where the chain is currently acting, so a rotation carries it + forward and a plain renewal stays put. +- `oauth_grant` — as above; FKs with `ON DELETE CASCADE`; no defaulted-to-`''` + columns (L3); `(clientId, userId)` indexed but **not** unique. No `scope`: it would + restate `resourceId`, since each resource grants exactly one. No `projectId` either — + see below. `revokedAt` is write-only on purpose — `status` is what code branches on, + and this answers "when" for anyone auditing later. + +All single-use consumption (pending record, code, refresh rotation) is an atomic +conditional `UPDATE … WHERE … AND consumedAt IS NULL` branching on affected rows (M1). + +## API-side enforcement (Node) + +- **`extractPrincipal` dispatch by `kid`:** HS256 legacy path unchanged. RS256 OAuth + path: verify against local keys, require `aud` = API audience (**positive** + enforcement — an `aud=mcp` token can never authenticate anywhere in the API, + including the websocket path, M3), map claims → `SERVICE` principal with `sub` as + userId, the grant's active project, and the user's **real project role** resolved + from membership (no hardcoded ADMIN); reject missing membership or inactive user. +- **Grant-status check:** for principals carrying `grant_id`, a cached (≈60 s, + in-process; Redis when available) single-row status read; revoked → 401. +- **Bearer/cookie precedence (L5):** the `Authorization` header wins over the `token` + cookie in `access-token-authn-handler.ts`, with regression tests for the app's + cookie-based flows. +- Route policies: OAuth-derived `SERVICE` principals flow through the existing ~40 + `[USER, SERVICE]` route policies unchanged. +- **`SERVICE`, never `USER`.** `ProjectAuthzHandler` rejects a request naming a project + other than the principal's, but enterprise's `/switch-project` is on that handler's + ignore list, because minting a token for another project is its whole job. What keeps + an OAuth connection out of it is its policy, + `getUnscopedRoutePolicy([PrincipalType.USER])`: a `SERVICE` principal gets + `403 invalid route for principal type`. Do not build the OAuth principal as `USER`, + and do not add `SERVICE` to `/switch-project`. OAuth connections switch project + through the token endpoint instead (below), which is the same capability with the + membership check kept in one place. Pinned by + `test/unit/oauth/oauth-principal.test.ts`. +- **`SERVICE` is in `DEFAULT_ALLOWED_PRINCIPAL_TYPES`**, so a route that declares no + policy is reachable by an OAuth token. The project guard still applies, so this is a + project-scoped reachability question rather than a cross-project one — but it means + the set an OAuth connection can touch is "everything not explicitly restricted", + not "everything explicitly opened". Worth keeping in mind when adding routes. + +## Python resource server (`mcp-server/`) + +- `MCP_TRANSPORT=stdio` (unchanged, internal AI chat) or `http` (Streamable HTTP, + `stateless_http=true`). +- **Auth:** FastMCP `JWTVerifier` (`jwks_uri`, `issuer`, `audience = MCP canonical +URI`) wrapped in `RemoteAuthProvider` → serves RFC 9728 PRM (root and path-aware + variants) and enforces local validation. ASGI middleware adds + `WWW-Authenticate: Bearer resource_metadata="…"` on 401 (kept from the spike — it + was correct). Origin-header validation per MCP 2025-11-25 (403 on bad Origin). +- **Downstream calls:** httpx request hook obtains the API token from an + **exchange-token cache** keyed by `(sha256(subject token), projectId)` with TTL + `min(remaining subject exp, 60 s)`; on miss, calls `/v1/oauth/token` + (token-exchange) authenticated with its confidential-client credentials + (`client_secret_basic`, from env, provisioned at deploy). **Fail-closed:** exchange + failure aborts the tool call with an MCP auth error; no request ever leaves without + an `Authorization` header (M6). +- **No project switching:** a connection acts on the project fixed on its grant. + Multi-project access is enterprise (requirement 5), so the resource server keeps + no project state of its own — which is also what removes the class of bug behind + audit finding M5 rather than merely relocating it. +- No AS metadata is served from the RS origin. + +## Consent UI (react-ui) + +Consent and connection management live in one place — `/settings/connected-apps` — so the +user decides where they later review and revoke. + +- **Consent dialog**, shown over that page when the URL carries a `request_id`. Reads + only the id, fetches `GET /v1/oauth/requests/{id}` and renders the client name **from + the server**, never from URL params. Plain-language copy naming what is granted, + including that the connection may act in any project the user has access to. Approve + and Deny both POST the decision and navigate to the server-returned URL only. + Dismissing counts as Deny. The response deliberately carries **no project**: a + connection is not confined to one, and naming the project it starts in would read as a + limit that does not exist. +- **Connected apps list** on the same page: one row per authorization — not per + application, since two connections for the same agent are independently revocable — + with client name, when connected, when last used, and Disconnect behind a + confirmation. Hidden entirely by the `CONNECTED_APPS_ENABLED` flag when OAuth is off, + since every route it depends on is then unregistered. All strings i18n; `react` skill + patterns. + +## Abuse controls & hygiene + +- Rate limits (existing module, per-IP): `/register`, `/authorize`, `/token` + (failure-weighted so refresh cadence is never throttled), exchange failures. +- Cleanup job (existing system-jobs): indexed range-deletes of expired pending + records, codes, and expired refresh tokens; stale-client removal via + `NOT EXISTS` query (no full-table loads); runs hourly. + - **Retention is anchored to expiry, including for revoked rows.** A rotated refresh + token is kept until the moment it could no longer be presented anyway, because that + is exactly the window in which a replay must be recognised as _reuse_ — which revokes + the family and logs a security event — rather than reported as an unknown token. An + independent, shorter window would quietly turn a replay of an older token into a + plain `invalid refresh token`: still rejected, but with the compromise signal lost + precisely because the token was old. Growth is bounded by the refresh TTL, so pick + that TTL with the table in mind rather than adding a second knob here. + - The **handler is registered on every boot**, including when OAuth is disabled, and + returns immediately in that case. The schedule lives in Redis and outlives the boot + that created it, so an instance that enabled OAuth once and later turned it off still + has the job firing; with no handler registered the worker fails it hourly. +- Security telemetry: log DCR registrations, refresh-reuse family revocations, exchange + auth failures, revocations. + +## Configuration (system props) + +| Prop | Default | Purpose | +| ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | -------------------------------- | +| `OPS_OAUTH_ENABLED` | `false` | Registers AS routes + well-known | +| `OPS_OAUTH_ISSUER_URL` | derived from frontend URL | `iss`, metadata | +| `OPS_OAUTH_ACCESS_TOKEN_TTL_SECONDS` | 900 | | +| `OPS_OAUTH_REFRESH_TOKEN_TTL_DAYS` | 30 | | +| `OPS_OAUTH_SIGNING_KEY_PEM_PATH` | unset | operator-managed key override | +| `OPS_MCP_RESOURCE_URL` | unset | canonical MCP resource URI | +| RS env: `MCP_TRANSPORT`, `MCP_OAUTH_ISSUER`, `MCP_RESOURCE_URI`, `MCP_CLIENT_ID`, `MCP_CLIENT_SECRET`, `API_BASE_URL`, `OPENAPI_SCHEMA_URL` | | | + +Deploy: path routing on the public host — `/mcp` + PRM → RS; `/v1/oauth/*` + +well-known → Node API. + +## Testing + +Every audit finding becomes a regression test. Highlights: + +- **Protocol:** PKCE fail/pass, code replay (including **concurrent** replay — M1), + expiry, cross-client code, redirect mismatch, unknown resource, state/iss round-trip, + DCR field bounds, registered-grant-type enforcement. +- **Consent binding:** decision without a pending record fails; expired/consumed + record fails; client name rendered from DB; deny redirect validated (H3/H4). +- **Refresh:** rotation; family revocation on reuse; grant-revoked → refresh refused; + absolute expiry (H1/H2). +- **Audience:** `aud=mcp` token rejected by REST **and websocket**; `aud=api` accepted; + legacy HS256 tokens unaffected (M3); exchanged token unusable at the RS. +- **Exchange:** requires RS client credentials; revoked grant/inactive user refused; + cache respects TTL; fail-closed on AS outage (M6). +- **Keys:** boot generation idempotent across concurrent replicas; rotation keeps old + tokens valid until expiry; JWKS serves retiring keys. +- **Python:** JWKS validation (valid/expired/wrong-aud/wrong-iss), PRM contents, 401 + challenge header, project-switch persistence. +- **E2E:** scripted MCP client (DCR → authorize → consent → token → tool call → + refresh → revoke → cutoff) with SSO on and off; stdio AI-chat regression; CLI-style + direct flow (loopback + `resource=api`). + +## Phasing + +- **P1 — AS core:** signing keys + JWKS, entities/migrations, DCR, pending-auth + record + authorize, token endpoint (code/refresh/exchange, atomic consumption, + families), grants + revocation, OAuth error serializer, discovery docs, rate limits, + cleanup. Unit + integration tests. +- **P2 — API enforcement:** `extractPrincipal` kid dispatch + positive audience, + real-role principal mapping, grant-status check, bearer-over-cookie. Regression + suite. +- **P3 — Python RS:** http transport, JWKS verifier, PRM + challenge middleware, + exchange client + cache + fail-closed, project-switch tool. +- **P4 — UI:** consent dialog, connected-apps settings page. +- **P5 — Deploy/E2E:** config, path routing, Docker, E2E matrix. + +Status: P1, P2 and P4 are complete in this repository. P3 lives in the `openops-mcp` +repository and is complete apart from the project-switch tool — the API side of switching +is built and tested here, but nothing in the resource server calls it yet. P5 is +outstanding in `openops-mcp`: Dockerfile, README, and the Helm values for +`openops-cloud/helm-chart`. + +The switch tool needs somewhere to hold the selection, and **that is where audit finding +M5 came from** — an in-process `_active_project_by_user` map that leaked across sessions +and vanished on restart. HTTP mode runs `stateless_http`, so the same map would work on +one instance and silently diverge across replicas. Whatever holds the selection has to be +per-connection and either shared or carried in the request; reaching for a process-local +dictionary would reintroduce the finding this design set out to fix. + +## Verification + +Unit tests cover each module in isolation. Because those use in-memory +repositories, the guarantees that depend on database semantics are covered +separately in `test/integration/ce/oauth/`: that single-use consumption of codes, +pending records and refresh tokens is atomic under concurrency; that revocation +cascades to one connection's tokens only; and that the cleanup job deletes what has +expired and nothing else. Writing those found a real defect — date cutoffs bound as +ISO strings are compared _textually_ by drivers that store a different textual +format, which matched every row including future ones. Cutoffs are now bound as +`Date` objects (`oauth-query.ts`). + +The integration harness runs on SQLite with schema synchronisation, which is not +the production driver. It does replace hand-written mocks with a real ORM and real +SQL, which is where the risk was. + +## Deviations found during implementation + +Recorded here because each one changes what the code does versus what this +document originally specified. + +1. **Project role is resolved through a seam, not hardcoded.** The design called + for the user's "real project role", which this edition cannot provide: it has no + per-project role model, and session logins hardcode `'ADMIN'` too. Rather than + hardcode it in the OAuth path as well, the role comes from + `getOAuthProjectMembershipService().getForUser(...)`, which returns `'ADMIN'` + here and the member's actual role in an edition that has one. The v1 scope model + is still coarse — a single full-access scope means a connected agent can do what + its user can — but the role is no longer baked into OAuth code. +2. **No `userId` on the pending authorization record.** `GET /authorize` is + reachable before the user has logged in, so the acting user is not known when + the record is written; it is taken from the session when the decision is + submitted and recorded on the grant. +3. **Authorization codes carry no `grantId`.** The grant is created when the code + is redeemed, which is after the code exists. The code references the client + and user instead. +4. **A failed redemption consumes the code.** The code is claimed before PKCE and + the other parameters are checked, so one wrong `code_verifier` burns it. This + is deliberate — it allows exactly one verifier guess per code — and the cost + is only that a party who already holds a code can deny the legitimate client + that one code. +5. **The project moved onto the token; switching then came back, deliberately.** The + design originally wanted an all-projects grant with runtime switching. That became a + required `project_id` claim instead, which is what resolves audit finding M5 — no + mutable project state for sessions to share — and fixes each token's authority for + its whole life. + + An intermediate step went further and refused to build any switching at all, on the + grounds that enterprise's `/switch-project` already mints a token per project. That + was wrong twice over. `/switch-project` is unreachable for an OAuth connection (it + requires `PrincipalType.USER` and session cookies), so refusing to build a mechanism + did not defer the capability to enterprise — it removed it. And an agent confined to + one project is not the parity users expect from a tool acting on their behalf. + + Switching is therefore built here, at the token endpoint, where the membership check + already lives (see _Project authorization_). The claim stays immutable per token; what + moves is the connection, by asking for a new token. That keeps M5 resolved — still no + mutable server-side state — while making the capability reachable. + +6. **Revocation is effectively immediate on a single instance**, not merely + within the ~60 s cache TTL: revoking busts the in-process grant cache. The TTL + bound applies across replicas, whose caches are not invalidated. +7. **Six columns the design named were removed as write-only.** `oauth_client.lastUsedAt` + (usage is meaningful per connection, on the grant) and `oauth_signing_key.alg` (one + algorithm, reported by the JWKS from a constant) went first. A later sweep took + `oauth_client.scope`, `oauth_grant.scope` and `oauth_refresh_token.userId` for the + same reason — each was written, and in two cases echoed through an API response, but + never consulted for a decision. Scope is settled by the resource; the acting user is + settled by the grant. `oauth_grant.revokedAt` was kept despite being write-only: it is + an audit answer to "when", which `status` alone cannot give. + + `oauth_grant.projectId` was the sixth, and the only one whose removal fixed a bug + rather than just saving a column. It was read — as the default when a refresh named no + project — and that default was wrong: a plain renewal returned the connection to where + it started, discarding a switch made minutes earlier. The project moved to + `oauth_refresh_token`, which is the chain being rotated, so renewal now preserves it. + +8. **Bearer now beats the session cookie** in `access-token-authn-handler.ts` + (was cookie-first). A caller presenting a token is stating which identity it + wants; preferring an ambient cookie would authenticate it as someone else. + +## Deferred (tracked follow-ups) + +- CIMD client registration (SEP-991) — accept URL client_ids. +- Fine-grained scopes (read/write, per-capability) + incremental consent (SEP-835). +- DPoP sender-constrained tokens. +- Ed25519 signing option. +- Multi-tenant/central MCP topology (would reuse the JWKS trust model as-is). +- A user-supplied label per connection. Connections are currently told apart by + client name, creation time and last use, which is thin when someone connects the + same agent from two machines. + +## Out of scope + +- Per-project _consent_. A connection is authorized against the user's account and may + act in any project they can reach, which is the parity a tool acting on someone's + behalf needs. Letting a user grant one project and withhold another would be a + finer-grained consent model, and belongs with the scopes work below. +- API keys / PATs (M365 Copilot cannot use them). +- RFC 7592 client management endpoints. +- Changes to internal HS256 token flows (sessions, worker, engine, AI-chat stdio). diff --git a/docs/oauth-manual-testing.md b/docs/oauth-manual-testing.md new file mode 100644 index 0000000000..bb897dbe3e --- /dev/null +++ b/docs/oauth-manual-testing.md @@ -0,0 +1,197 @@ +# Testing external-agent OAuth locally + +How to exercise the OAuth 2.1 authorization server by hand. Design: +`docs/oauth-design.md` (OPS-4673). + +The whole chain works end to end: an MCP client discovers the server, registers, +opens the browser at the consent screen, and receives a token. Two ways to test it +— [by hand with the script](#walk-the-whole-flow), which needs no browser, or +[with a real client](#connect-a-real-client), which is what users will do. + +The MCP resource server lives in its own repository, `openops-mcp`. + +## Start the API with OAuth on + +OAuth is off by default and every route 404s until it is enabled. Postgres is +required — the migration is registered for Postgres only. + +```bash +docker compose up -d --wait + +export $(grep -v '^#' .env | xargs) # your usual local settings +export PATH="$PWD/node_modules/.bin:$PATH" # the block rebuild step needs nx + +export OPS_OAUTH_ENABLED=true +export OPS_OAUTH_ISSUER_URL=http://localhost:3000 # public base URL of this API +export OPS_MCP_RESOURCE_URL=http://localhost:3020/mcp +export OPS_OAUTH_RS_CLIENT_SECRET=$(openssl rand -hex 32) + +npx nx build server-api && node dist/packages/server/api/main.js +``` + +First boot generates the RS256 signing keypair and logs +`OAuth authorization server enabled`. Nothing else is needed: the keypair is +created automatically and stored encrypted. + +Sanity check, in another shell: + +```bash +curl -s localhost:3000/.well-known/oauth-authorization-server | jq +curl -s localhost:3000/v1/oauth/jwks.json | jq '.keys[0] | {kty, alg, kid}' +``` + +## Walk the whole flow + +```bash +tools/oauth-flow.sh # api resource — a CLI or partner agent calling REST directly +tools/oauth-flow.sh mcp # mcp resource — adds the token-exchange step +``` + +Pass `OPS_OAUTH_RS_CLIENT_SECRET` with the same value the API was started with; +the `mcp` mode authenticates as the resource server. The script registers a +client, authorizes, approves consent, redeems the code, calls the API, rotates +the refresh token, and revokes the connection — printing the token claims at each +step so you can see what a client actually receives. + +The two modes differ in one way that matters: with `mcp`, the client's own token +is **refused** by the API (401) and has to be exchanged for a separate +API-audience token first. That is the no-token-passthrough rule, and the script +asserts it. + +## Connect a real client + +This is the path a user takes, and the only one that exercises the consent screen. +You need the frontend running (`npx nx serve react-ui`, port 4200) as well as the +API, and `OPS_FRONTEND_URL` pointing at it — that is what the authorize endpoint +redirects the browser to. + +Start the MCP resource server from the `openops-mcp` repository: + +```bash +cd ../openops-mcp +MCP_TRANSPORT=http \ +OPENOPS_API_URL=http://localhost:3000 \ +OPENOPS_MCP_ROUTES=config/routes.oss.yaml \ +OPENOPS_MCP_ISSUER=http://localhost:3000 \ +OPENOPS_MCP_RESOURCE_URL=http://localhost:3020/mcp \ +OPENOPS_MCP_CLIENT_SECRET="$OPS_OAUTH_RS_CLIENT_SECRET" \ +uv run openops-mcp +``` + +Then point a client at it. With Claude Code: + +```bash +claude mcp add --transport http openops http://localhost:3020/mcp +``` + +The client discovers the authorization server, registers itself, and opens your +browser at **Settings → Connected apps**, with the consent dialog over it. Sign in +if you are not already. Approving sends the browser back to the client, which +redeems the code and lists the tools. + +Worth confirming while you are here: + +- **The project is named in the dialog**, and it matches `project_id` in the + issued token — that claim is what every later request is authorized against. +- **Cancelling** returns the client to its callback with `error=access_denied`. + So does dismissing the dialog: the client is waiting on its redirect, and + telling it no beats leaving it to time out. +- **Reloading the page** after deciding shows the expired-request message rather + than a second consent dialog. The pending record is single-use. +- **Connecting a second client** (or the same one again) produces an independent + connection. Both appear as separate rows on that page, and disconnecting one + leaves the other working — which is the point of the per-connection model. +- **The page is hidden** when `OPS_OAUTH_ENABLED` is false, because every route it + depends on is unregistered. + +## Switching project + +A connection acts wherever the user can, not only where it started. With a token in +hand: + +```bash +# Where may this connection go, and where is it now? +curl -s localhost:3000/v1/oauth/projects -H "Authorization: Bearer $TOKEN" | jq + +# Move a direct API client. +curl -s -X POST localhost:3000/v1/oauth/token \ + -d "grant_type=refresh_token&refresh_token=$REFRESH&client_id=$CID&project_id=$OTHER" | jq + +# Move a resource server on an agent's behalf — the Claude Code path. +curl -s -X POST localhost:3000/v1/oauth/token \ + -u "openops-mcp-rs:$OPS_OAUTH_RS_CLIENT_SECRET" \ + -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=$MCP_TOKEN&project_id=$OTHER" | jq +``` + +Naming a project the user is not a member of returns `invalid_target`, and on the +refresh path the refusal happens before the token is consumed — so a wrong guess does +not cost a working connection. Decode `project_id` from the returned access token to +confirm the move. + +This edition has one project per organization, so there is usually nowhere else to go. +To exercise it, add a second project to the same organization — note that +`tablesDatabaseToken` must be a genuinely encrypted value, since the API decrypts it at +boot and will refuse to start on a malformed one. + +## Things worth poking at by hand + +Each of these should produce a clean OAuth error, never a 500: + +```bash +CID=$(curl -s -X POST localhost:3000/v1/oauth/register -H 'Content-Type: application/json' \ + -d '{"client_name":"Probe","redirect_uris":["http://127.0.0.1:41100/callback"]}' | jq -r .client_id) +AUTH="localhost:3000/v1/oauth/authorize?client_id=$CID&redirect_uri=http%3A%2F%2F127.0.0.1%3A41100%2Fcallback&response_type=code&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&resource=http%3A%2F%2Flocalhost%3A3020%2Fmcp" + +# Unregistered redirect_uri: renders an error, must NOT redirect (open-redirect boundary) +curl -si "localhost:3000/v1/oauth/authorize?client_id=$CID&redirect_uri=https%3A%2F%2Fattacker.example%2Fsteal&response_type=code&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&resource=http%3A%2F%2Flocalhost%3A3020%2Fmcp" | head -1 + +# Missing PKCE: redirects back to the *registered* uri with error + state + iss +curl -si "localhost:3000/v1/oauth/authorize?client_id=$CID&redirect_uri=http%3A%2F%2F127.0.0.1%3A41100%2Fcallback&response_type=code&resource=http%3A%2F%2Flocalhost%3A3020%2Fmcp&state=s" | grep -i location + +# Registration refuses non-loopback http and consent-skipping grants +curl -s -X POST localhost:3000/v1/oauth/register -H 'Content-Type: application/json' \ + -d '{"client_name":"E","redirect_uris":["http://evil.example/cb"]}' | jq +curl -s -X POST localhost:3000/v1/oauth/register -H 'Content-Type: application/json' \ + -d '{"client_name":"E","redirect_uris":["https://a.example/cb"],"grant_types":["implicit"]}' | jq + +# Consent decision without the anti-CSRF header. Needs a session first — without +# one you get `missing access token`, because the route requires a logged-in user +# before it looks at anything else. +curl -s -c /tmp/ck -X POST localhost:3000/v1/authentication/sign-in \ + -H 'Content-Type: application/json' \ + -d '{"email":"local-admin@openops.com","password":"12345678"}' -o /dev/null +curl -s -b /tmp/ck -X POST "localhost:3000/v1/oauth/requests/anything/decision" \ + -H 'Content-Type: application/json' -d '{"approve":true}' | jq +# -> invalid_request: the x-openops-consent header is required +``` + +To check that **connections are independent**, run `tools/oauth-flow.sh` twice +without revoking in between, then look at Settings → Connected apps (or +`GET /v1/oauth/grants`): two rows for the same client, each revocable on its own. + +## Inspecting state + +```bash +docker exec postgres psql -U postgres -d openops -c \ + "SELECT id, \"clientId\", \"projectId\", status, \"lastUsedAt\" FROM oauth_grant ORDER BY created DESC;" + +docker exec postgres psql -U postgres -d openops -c \ + "SELECT \"grantId\", \"familyId\", \"revokedAt\" IS NOT NULL AS revoked FROM oauth_refresh_token ORDER BY created DESC;" +``` + +The hourly cleanup job is registered at boot. Confirm it is scheduled with: + +```bash +docker exec redis redis-cli zrange "bull:system-job-queue:repeat" 0 -1 | grep oauth +``` + +## Resetting between runs + +```bash +docker exec postgres psql -U postgres -d openops -c \ + "DROP TABLE IF EXISTS oauth_refresh_token, oauth_authorization_code, + oauth_pending_authorization, oauth_grant, oauth_client, oauth_signing_key CASCADE; + DELETE FROM migrations WHERE name = 'CreateOAuthTables1785312000000';" +``` + +The migration re-runs on the next boot and a fresh signing key is generated. diff --git a/packages/react-ui/src/app/common/components/project-settings-layout.tsx b/packages/react-ui/src/app/common/components/project-settings-layout.tsx index d11c3bfb80..7df911624c 100644 --- a/packages/react-ui/src/app/common/components/project-settings-layout.tsx +++ b/packages/react-ui/src/app/common/components/project-settings-layout.tsx @@ -1,32 +1,13 @@ import { FlagId } from '@openops/shared'; import { t } from 'i18next'; -import { Settings, Sparkles, SunMoon } from 'lucide-react'; +import { Plug, Settings, Sparkles, SunMoon } from 'lucide-react'; +import { useMemo } from 'react'; import SidebarLayout from '@/app/common/components/sidebar-layout'; import { flagsHooks } from '@/app/common/hooks/flags-hooks'; const iconSize = 20; -const baseNavItems = [ - { - title: t('General'), - href: '/settings/general', - icon: , - }, -]; - -const appearanceNavItem = { - title: t('Appearance'), - href: '/settings/appearance', - icon: , -}; - -const aiNavItem = { - title: t('OpenOps AI'), - href: '/settings/ai', - icon: , -}; - interface SettingsLayoutProps { children: React.ReactNode; } @@ -38,11 +19,53 @@ export default function ProjectSettingsLayout({ FlagId.DARK_THEME_ENABLED, ).data; - const sidebarNavItems = [ - ...baseNavItems, - ...(showAppearanceSettings ? [appearanceNavItem] : []), - aiNavItem, - ]; + // Hidden unless the instance can actually accept external connections: with OAuth + // off, every route the page depends on is unregistered. + const showConnectedApps = flagsHooks.useFlag( + FlagId.CONNECTED_APPS_ENABLED, + ).data; + + /* + * Titles are resolved here rather than in module-scope constants (OPS-4318). + * + * A production build can place this module in a chunk that evaluates before the entry + * chunk runs `i18n.init()`. `t()` returns undefined until then, and a title captured + * in a top-level constant would freeze that undefined — a nav item with no text, in + * builds only. Inside the component the call happens at render, long after init. + */ + const sidebarNavItems = useMemo( + () => [ + { + title: t('General'), + href: '/settings/general', + icon: , + }, + ...(showAppearanceSettings + ? [ + { + title: t('Appearance'), + href: '/settings/appearance', + icon: , + }, + ] + : []), + { + title: t('OpenOps AI'), + href: '/settings/ai', + icon: , + }, + ...(showConnectedApps + ? [ + { + title: t('Connected apps'), + href: '/settings/connected-apps', + icon: , + }, + ] + : []), + ], + [showAppearanceSettings, showConnectedApps], + ); return {children}; } diff --git a/packages/react-ui/src/app/constants/query-keys.ts b/packages/react-ui/src/app/constants/query-keys.ts index 2bea8e4af6..d6b09ba7f9 100644 --- a/packages/react-ui/src/app/constants/query-keys.ts +++ b/packages/react-ui/src/app/constants/query-keys.ts @@ -61,6 +61,10 @@ export const QueryKeys = { // Cloud cloudUserInfo: 'cloud-user-info', + // OAuth + oauthConsentRequest: 'oauth-consent-request', + connectedApps: 'connected-apps', + // Connections appConnections: 'app-connections', appConnection: 'app-connection', diff --git a/packages/react-ui/src/app/features/oauth/components/connected-apps-list.tsx b/packages/react-ui/src/app/features/oauth/components/connected-apps-list.tsx new file mode 100644 index 0000000000..1d47fbb19e --- /dev/null +++ b/packages/react-ui/src/app/features/oauth/components/connected-apps-list.tsx @@ -0,0 +1,117 @@ +import { formatUtils } from '@/app/lib/utils'; +import { Button } from '@openops/components/ui'; +import { t } from 'i18next'; +import { Plug } from 'lucide-react'; +import { ConnectedApp, OAuthResourceId } from '../lib/oauth-api'; + +/** + * How the application reaches OpenOps. Worth showing because it is the one thing that + * distinguishes otherwise identical rows, and it is not derivable from anything else the + * row displays. + */ +const describeResource = (resourceId: OAuthResourceId | null): string => { + if (resourceId === 'mcp') { + return t('via the MCP server'); + } + if (resourceId === 'api') { + return t('via the API'); + } + return t('unknown connection type'); +}; + +type ConnectedAppsListProps = { + apps: ConnectedApp[]; + onRevoke: (app: ConnectedApp) => void; + revokingId: string | null; +}; + +const EmptyState = () => ( +
+ + + {t('No applications are connected')} + + + {t( + 'When you connect an AI agent or another application to OpenOps, it will appear here and you can disconnect it at any time.', + )} + +
+); + +const ConnectedAppRow = ({ + app, + onRevoke, + isRevoking, +}: { + app: ConnectedApp; + onRevoke: (app: ConnectedApp) => void; + isRevoking: boolean; +}) => ( +
+
+ {/* Stands in for the product logo an integration card shows. Connected + applications are self-registered, so there is no artwork to use. */} +
+ +
+ +
+ + {app.clientName} + + + {describeResource(app.resourceId)} + {' · '} + {t('connected')} {formatUtils.formatDate(new Date(app.created))} + {' · '} + {app.lastUsedAt + ? `${t('last used')} ${formatUtils.formatDate( + new Date(app.lastUsedAt), + )}` + : t('never used')} + +
+
+ + +
+); + +/** + * One row per authorization, not per application. Connecting the same application + * twice produces two rows, and each is disconnected on its own — which is what lets a + * user keep one agent working while cutting off another. + */ +const ConnectedAppsList = ({ + apps, + onRevoke, + revokingId, +}: ConnectedAppsListProps) => { + if (apps.length === 0) { + return ; + } + + return ( +
+ {apps.map((app) => ( + + ))} +
+ ); +}; + +ConnectedAppsList.displayName = 'ConnectedAppsList'; +export { ConnectedAppsList }; diff --git a/packages/react-ui/src/app/features/oauth/components/consent-dialog.tsx b/packages/react-ui/src/app/features/oauth/components/consent-dialog.tsx new file mode 100644 index 0000000000..501ccc317f --- /dev/null +++ b/packages/react-ui/src/app/features/oauth/components/consent-dialog.tsx @@ -0,0 +1,98 @@ +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@openops/components/ui'; +import { t } from 'i18next'; +import { OAuthConsentRequest } from '../lib/oauth-api'; + +type ConsentDialogProps = { + request: OAuthConsentRequest; + onApprove: () => void; + onDeny: () => void; + isDeciding: boolean; +}; + +/** + * What the connection will be able to do, in the user's terms. + * + * Stated as the upper bound and not varied by resource. A connection to the MCP server + * reaches the API by exchanging its token for an API one, and how much of the API the + * MCP server exposes is a deployment setting this screen cannot see — so promising + * anything narrower here would be a promise it cannot keep. + */ +const describeAccess = (): string[] => [ + t('View your workflows, runs, and connections'), + t('Create and change workflows on your behalf'), + t('Run workflows and retry runs'), + // The widest thing being granted, so it is stated rather than implied. Naming one + // project here instead would read as a limit, and there is no limit to read: a + // connection can move to any project its user can reach. + t('Act in any project you have access to'), +]; + +const ConsentDialog = ({ + request, + onApprove, + onDeny, + isDeciding, +}: ConsentDialogProps) => ( + { + if (!open && !isDeciding) { + onDeny(); + } + }} + > + + + {t('Authorize access')} + + {request.clientName}{' '} + {t('is asking to access OpenOps as you.')} + + + +
+
+ + {t('It will be able to:')} + +
    + {describeAccess().map((item) => ( +
  • + {item} +
  • + ))} +
+
+ +

+ {t( + 'Only continue if you started this from the application named above. You can disconnect it later from this page.', + )} +

+
+ + + + + +
+
+); + +ConsentDialog.displayName = 'ConsentDialog'; +export { ConsentDialog }; diff --git a/packages/react-ui/src/app/features/oauth/hooks/tests/use-connected-apps.test.tsx b/packages/react-ui/src/app/features/oauth/hooks/tests/use-connected-apps.test.tsx new file mode 100644 index 0000000000..5bd2d6823d --- /dev/null +++ b/packages/react-ui/src/app/features/oauth/hooks/tests/use-connected-apps.test.tsx @@ -0,0 +1,111 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { ConnectedApp, oauthApi } from '../../lib/oauth-api'; +import { useConnectedApps } from '../use-connected-apps'; + +jest.mock('../../lib/oauth-api', () => ({ + oauthApi: { listConnectedApps: jest.fn(), revokeConnectedApp: jest.fn() }, +})); + +const mockedList = oauthApi.listConnectedApps as jest.Mock; +const mockedRevoke = oauthApi.revokeConnectedApp as jest.Mock; + +const app = (id: string, clientName = 'Claude Code'): ConnectedApp => ({ + id, + clientName, + resourceId: 'mcp', + created: '2026-07-01T10:00:00.000Z', + lastUsedAt: null, +}); + +// Retries are disabled here only to keep the failure cases fast. Unlike the consent +// request, which is single-use and opts out in the hook, retrying this list is +// reasonable behaviour — it just is not what these tests are about. +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +const render = () => renderHook(() => useConnectedApps(), { wrapper }); + +beforeEach(() => { + jest.clearAllMocks(); + mockedList.mockResolvedValue([app('grant-1'), app('grant-2')]); + mockedRevoke.mockResolvedValue(undefined); +}); + +describe('useConnectedApps', () => { + it('lists the connections the user has granted', async () => { + const { result } = render(); + + await waitFor(() => expect(result.current.apps).toHaveLength(2)); + expect(result.current.apps?.map((a) => a.id)).toEqual([ + 'grant-1', + 'grant-2', + ]); + }); + + it('revokes only the connection asked for', async () => { + const { result } = render(); + await waitFor(() => expect(result.current.apps).toHaveLength(2)); + + await act(async () => result.current.revoke('grant-2')); + + // Two rows can belong to the same application, so the id is what identifies + // which authorization to cut off. react-query passes its own context as a second + // argument, so only the first is asserted. + expect(mockedRevoke).toHaveBeenCalledTimes(1); + expect(mockedRevoke.mock.calls[0][0]).toBe('grant-2'); + }); + + it('refetches the list after revoking so the row disappears', async () => { + const { result } = render(); + await waitFor(() => expect(result.current.apps).toHaveLength(2)); + + mockedList.mockResolvedValue([app('grant-1')]); + await act(async () => result.current.revoke('grant-2')); + + await waitFor(() => expect(result.current.apps).toHaveLength(1)); + expect(result.current.apps?.[0].id).toBe('grant-1'); + }); + + it('reports which connection is being revoked, and only that one', async () => { + let finish: () => void = () => undefined; + mockedRevoke.mockImplementation( + () => new Promise((resolve) => (finish = resolve)), + ); + + const { result } = render(); + await waitFor(() => expect(result.current.apps).toHaveLength(2)); + + act(() => result.current.revoke('grant-2')); + await waitFor(() => expect(result.current.revokingId).toBe('grant-2')); + + await act(async () => finish()); + await waitFor(() => expect(result.current.revokingId).toBeNull()); + }); + + it('surfaces a failed revoke and refetches so the row is not wrongly removed', async () => { + mockedRevoke.mockRejectedValue(new Error('gone')); + const { result } = render(); + await waitFor(() => expect(result.current.apps).toHaveLength(2)); + + await act(async () => result.current.revoke('grant-2')); + + await waitFor(() => expect(result.current.revokeError).not.toBeNull()); + expect(result.current.apps).toHaveLength(2); + }); + + it('surfaces a failed load', async () => { + mockedList.mockRejectedValue(new Error('oauth disabled')); + + const { result } = render(); + + await waitFor(() => expect(result.current.loadError).not.toBeNull()); + expect(result.current.apps).toBeUndefined(); + }); +}); diff --git a/packages/react-ui/src/app/features/oauth/hooks/tests/use-oauth-consent.test.tsx b/packages/react-ui/src/app/features/oauth/hooks/tests/use-oauth-consent.test.tsx new file mode 100644 index 0000000000..d86b224ed7 --- /dev/null +++ b/packages/react-ui/src/app/features/oauth/hooks/tests/use-oauth-consent.test.tsx @@ -0,0 +1,107 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { oauthApi, OAuthConsentRequest } from '../../lib/oauth-api'; +import { useOAuthConsent } from '../use-oauth-consent'; + +jest.mock('../../lib/oauth-api', () => ({ + oauthApi: { getConsentRequest: jest.fn(), decide: jest.fn() }, +})); + +const mockedGetConsentRequest = oauthApi.getConsentRequest as jest.Mock; +const mockedDecide = oauthApi.decide as jest.Mock; + +const REQUEST: OAuthConsentRequest = { + requestId: 'req-1', + clientName: 'Claude Code', +}; + +const assign = jest.fn(); + +// Deliberately left at react-query's defaults, which retry failed queries. The hook is +// responsible for opting out, so overriding it here would hide that. +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +const render = (requestId: string | null) => + renderHook(() => useOAuthConsent(requestId), { wrapper }); + +beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { assign }, + writable: true, + }); +}); + +beforeEach(() => { + jest.clearAllMocks(); + mockedGetConsentRequest.mockResolvedValue(REQUEST); + mockedDecide.mockResolvedValue({ redirectTo: 'https://client/cb?code=abc' }); +}); + +describe('useOAuthConsent', () => { + it('exposes the pending request once loaded', async () => { + const { result } = render('req-1'); + + await waitFor(() => expect(result.current.request).toEqual(REQUEST)); + expect(mockedGetConsentRequest).toHaveBeenCalledWith('req-1'); + }); + + it('does not ask the server for a request that was never identified', async () => { + const { result } = render(null); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(mockedGetConsentRequest).not.toHaveBeenCalled(); + }); + + it('sends the browser to the redirect the server returned when approving', async () => { + const { result } = render('req-1'); + await waitFor(() => expect(result.current.request).toEqual(REQUEST)); + + await act(async () => result.current.approve()); + + expect(mockedDecide).toHaveBeenCalledWith('req-1', true); + // A full navigation, because the destination belongs to the calling client. + expect(assign).toHaveBeenCalledWith('https://client/cb?code=abc'); + }); + + it('sends the browser to the error redirect when denying', async () => { + mockedDecide.mockResolvedValue({ + redirectTo: 'https://client/cb?error=access_denied', + }); + const { result } = render('req-1'); + await waitFor(() => expect(result.current.request).toEqual(REQUEST)); + + await act(async () => result.current.deny()); + + expect(mockedDecide).toHaveBeenCalledWith('req-1', false); + expect(assign).toHaveBeenCalledWith( + 'https://client/cb?error=access_denied', + ); + }); + + it('surfaces a failed load without retrying it', async () => { + mockedGetConsentRequest.mockRejectedValue(new Error('expired')); + + const { result } = render('req-1'); + + await waitFor(() => expect(result.current.loadError).not.toBeNull()); + expect(result.current.request).toBeUndefined(); + // A pending request is single-use: re-reading it cannot succeed. + expect(mockedGetConsentRequest).toHaveBeenCalledTimes(1); + }); + + it('surfaces a failed decision and leaves the browser where it is', async () => { + mockedDecide.mockRejectedValue(new Error('gone')); + const { result } = render('req-1'); + await waitFor(() => expect(result.current.request).toEqual(REQUEST)); + + await act(async () => result.current.approve()); + + await waitFor(() => expect(result.current.decisionError).not.toBeNull()); + expect(assign).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-ui/src/app/features/oauth/hooks/use-connected-apps.ts b/packages/react-ui/src/app/features/oauth/hooks/use-connected-apps.ts new file mode 100644 index 0000000000..42a7269333 --- /dev/null +++ b/packages/react-ui/src/app/features/oauth/hooks/use-connected-apps.ts @@ -0,0 +1,54 @@ +import { QueryKeys } from '@/app/constants/query-keys'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { ConnectedApp, oauthApi } from '../lib/oauth-api'; + +type UseConnectedApps = { + apps: ConnectedApp[] | undefined; + isLoading: boolean; + loadError: Error | null; + revoke: (grantId: string) => void; + revokingId: string | null; + revokeError: Error | null; +}; + +/** + * The applications this user has connected, and the ability to disconnect one. + * + * Each row is a separate authorization rather than a separate application: connecting + * the same client twice produces two, and revoking one leaves the other working. + */ +export const useConnectedApps = (): UseConnectedApps => { + const queryClient = useQueryClient(); + + const { + data: apps, + isLoading, + error: loadError, + } = useQuery({ + queryKey: [QueryKeys.connectedApps], + queryFn: oauthApi.listConnectedApps, + }); + + const { + mutate, + variables: revokingId, + isPending: isRevoking, + error: revokeError, + } = useMutation({ + mutationFn: oauthApi.revokeConnectedApp, + onSettled: () => + queryClient.invalidateQueries({ queryKey: [QueryKeys.connectedApps] }), + }); + + const revoke = useCallback((grantId: string) => mutate(grantId), [mutate]); + + return { + apps, + isLoading, + loadError: loadError as Error | null, + revoke, + revokingId: isRevoking ? revokingId ?? null : null, + revokeError: revokeError as Error | null, + }; +}; diff --git a/packages/react-ui/src/app/features/oauth/hooks/use-oauth-consent.ts b/packages/react-ui/src/app/features/oauth/hooks/use-oauth-consent.ts new file mode 100644 index 0000000000..db8fafa26f --- /dev/null +++ b/packages/react-ui/src/app/features/oauth/hooks/use-oauth-consent.ts @@ -0,0 +1,63 @@ +import { QueryKeys } from '@/app/constants/query-keys'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { oauthApi, OAuthConsentRequest } from '../lib/oauth-api'; + +type UseOAuthConsent = { + request: OAuthConsentRequest | undefined; + isLoading: boolean; + loadError: Error | null; + approve: () => void; + deny: () => void; + isDeciding: boolean; + decisionError: Error | null; +}; + +/** + * Loads a pending authorization request and records the user's decision. + * + * The request is single-use: the server consumes it when a decision arrives, so this + * never retries and never refetches. A second read would fail, and a second decision + * is exactly what the single-use record exists to prevent. + */ +export const useOAuthConsent = (requestId: string | null): UseOAuthConsent => { + const { + data: request, + isLoading, + error: loadError, + } = useQuery({ + queryKey: [QueryKeys.oauthConsentRequest, requestId], + queryFn: () => oauthApi.getConsentRequest(requestId as string), + enabled: requestId !== null, + retry: false, + staleTime: Infinity, + refetchOnWindowFocus: false, + }); + + const { + mutate, + isPending: isDeciding, + error: decisionError, + } = useMutation({ + mutationFn: (approve: boolean) => + oauthApi.decide(requestId as string, approve), + onSuccess: ({ redirectTo }) => { + // A full navigation, not a router push: the destination belongs to the client + // that started the flow. The server only ever returns a registered redirect URI. + window.location.assign(redirectTo); + }, + }); + + const approve = useCallback(() => mutate(true), [mutate]); + const deny = useCallback(() => mutate(false), [mutate]); + + return { + request, + isLoading: requestId !== null && isLoading, + loadError: loadError as Error | null, + approve, + deny, + isDeciding, + decisionError: decisionError as Error | null, + }; +}; diff --git a/packages/react-ui/src/app/features/oauth/lib/oauth-api.ts b/packages/react-ui/src/app/features/oauth/lib/oauth-api.ts new file mode 100644 index 0000000000..6d82671c00 --- /dev/null +++ b/packages/react-ui/src/app/features/oauth/lib/oauth-api.ts @@ -0,0 +1,61 @@ +import { api } from '@/app/lib/api'; + +/** + * Required on the decision. A cross-site form post cannot set a custom header, which + * is what stops a third party from driving the decision on a logged-in user's behalf. + */ +const CONSENT_HEADER = 'x-openops-consent'; + +export type OAuthResourceId = 'api' | 'mcp'; + +export type OAuthConsentRequest = { + requestId: string; + clientName: string; +}; + +export type OAuthConsentDecision = { + /** Where to send the browser next. Always one of the client's registered URIs. */ + redirectTo: string; +}; + +/** One authorization the user granted. Each is revocable on its own. */ +export type ConnectedApp = { + id: string; + clientName: string; + resourceId: OAuthResourceId | null; + created: string; + lastUsedAt: string | null; +}; + +type ListConnectedAppsResponse = { + data: ConnectedApp[]; +}; + +const getConsentRequest = (requestId: string): Promise => + api.get(`/v1/oauth/requests/${requestId}`); + +const decide = ( + requestId: string, + approve: boolean, +): Promise => + api.post( + `/v1/oauth/requests/${requestId}/decision`, + { approve }, + undefined, + { [CONSENT_HEADER]: '1' }, + ); + +const listConnectedApps = (): Promise => + api + .get('/v1/oauth/grants') + .then((response) => response.data); + +const revokeConnectedApp = (grantId: string): Promise => + api.delete(`/v1/oauth/grants/${grantId}`); + +export const oauthApi = { + getConsentRequest, + decide, + listConnectedApps, + revokeConnectedApp, +}; diff --git a/packages/react-ui/src/app/features/oauth/lib/tests/oauth-api.test.ts b/packages/react-ui/src/app/features/oauth/lib/tests/oauth-api.test.ts new file mode 100644 index 0000000000..52462ede25 --- /dev/null +++ b/packages/react-ui/src/app/features/oauth/lib/tests/oauth-api.test.ts @@ -0,0 +1,63 @@ +import { api } from '@/app/lib/api'; +import { oauthApi } from '../oauth-api'; + +jest.mock('@/app/lib/api', () => ({ + api: { get: jest.fn(), post: jest.fn(), delete: jest.fn() }, +})); + +const mockedGet = api.get as jest.Mock; +const mockedPost = api.post as jest.Mock; +const mockedDelete = api.delete as jest.Mock; + +describe('oauthApi', () => { + beforeEach(() => { + mockedGet.mockReset().mockResolvedValue({}); + mockedPost.mockReset().mockResolvedValue({ redirectTo: 'https://client' }); + mockedDelete.mockReset().mockResolvedValue(undefined); + }); + + it('reads a pending request by id', async () => { + await oauthApi.getConsentRequest('req-1'); + + expect(mockedGet).toHaveBeenCalledWith('/v1/oauth/requests/req-1'); + }); + + it('sends the consent header with the decision', async () => { + await oauthApi.decide('req-1', true); + + // The server refuses a decision without this header, which is what stops a + // cross-site form post from answering on a signed-in user's behalf. + expect(mockedPost).toHaveBeenCalledWith( + '/v1/oauth/requests/req-1/decision', + { approve: true }, + undefined, + { 'x-openops-consent': '1' }, + ); + }); + + it('unwraps the connected apps list', async () => { + mockedGet.mockResolvedValue({ data: [{ id: 'grant-1' }] }); + + await expect(oauthApi.listConnectedApps()).resolves.toEqual([ + { id: 'grant-1' }, + ]); + expect(mockedGet).toHaveBeenCalledWith('/v1/oauth/grants'); + }); + + it('revokes one connection by its own id', async () => { + await oauthApi.revokeConnectedApp('grant-2'); + + expect(mockedDelete).toHaveBeenCalledWith('/v1/oauth/grants/grant-2'); + }); + + it('carries a denial through as approve false', async () => { + await oauthApi.decide('req-1', false); + + expect(mockedPost).toHaveBeenCalledWith( + '/v1/oauth/requests/req-1/decision', + { approve: false }, + undefined, + expect.anything(), + ); + }); +}); diff --git a/packages/react-ui/src/app/lib/api.ts b/packages/react-ui/src/app/lib/api.ts index b754975ef7..4a09026195 100644 --- a/packages/react-ui/src/app/lib/api.ts +++ b/packages/react-ui/src/app/lib/api.ts @@ -62,11 +62,12 @@ export const api = { url: string, body?: TBody, params?: TParams, + headers: Record = {}, ) => request(url, { method: 'POST', data: body, - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...headers }, params: params, }), diff --git a/packages/react-ui/src/app/router.tsx b/packages/react-ui/src/app/router.tsx index 57895df73a..53de7047ba 100644 --- a/packages/react-ui/src/app/router.tsx +++ b/packages/react-ui/src/app/router.tsx @@ -46,6 +46,10 @@ import GeneralPage from './routes/settings/general'; import { SignInPage } from './routes/sign-in'; import { SignUpPage } from './routes/sign-up'; +const ConnectedAppsPage = lazy( + () => import('@/app/routes/settings/connected-apps'), +); + const SettingsRerouter = () => { const { hash } = useLocation(); const fragmentWithoutHash = hash.slice(1).toLowerCase(); @@ -290,6 +294,24 @@ const createRoutes = ({ routes.push(...regularLoginRoutes); } + routes.push({ + path: 'settings/connected-apps', + element: ( + }> + + + + + + + + + + + ), + errorElement: , + }); + const redirectRoutes = [ { path: 'redirect', diff --git a/packages/react-ui/src/app/routes/settings/connected-apps/connected-apps-page.tsx b/packages/react-ui/src/app/routes/settings/connected-apps/connected-apps-page.tsx new file mode 100644 index 0000000000..e9e23551b3 --- /dev/null +++ b/packages/react-ui/src/app/routes/settings/connected-apps/connected-apps-page.tsx @@ -0,0 +1,136 @@ +import { ConnectedAppsList } from '@/app/features/oauth/components/connected-apps-list'; +import { ConsentDialog } from '@/app/features/oauth/components/consent-dialog'; +import { useConnectedApps } from '@/app/features/oauth/hooks/use-connected-apps'; +import { useOAuthConsent } from '@/app/features/oauth/hooks/use-oauth-consent'; +import { ConnectedApp } from '@/app/features/oauth/lib/oauth-api'; +import { + Alert, + AlertDescription, + AlertTitle, + ConfirmationDialog, + LoadingSpinner, +} from '@openops/components/ui'; +import { t } from 'i18next'; +import { useCallback, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +const REQUEST_ID_PARAM = 'request_id'; + +const PageError = ({ + title, + description, +}: { + title: string; + description: string; +}) => ( + + {title} + {description} + +); + +const ConnectedAppsPage = () => { + const [searchParams] = useSearchParams(); + const requestId = searchParams.get(REQUEST_ID_PARAM); + + const consent = useOAuthConsent(requestId); + const { apps, isLoading, loadError, revoke, revokingId, revokeError } = + useConnectedApps(); + + const [appToRevoke, setAppToRevoke] = useState(null); + + const confirmRevoke = useCallback(() => { + if (appToRevoke) { + revoke(appToRevoke.id); + setAppToRevoke(null); + } + }, [appToRevoke, revoke]); + + const cancelRevoke = useCallback(() => setAppToRevoke(null), []); + + return ( + // Same shape as the other settings routes, so the page title and description read + // the same wherever you land (see `routes/settings/ai`). +
+
+

{t('Connected apps')}

+

+ {t( + 'AI agents and other applications you have allowed to act in OpenOps on your behalf. Disconnecting one takes effect immediately and does not affect the others.', + )} +

+ + {/* A pending request that cannot be read is almost always expired, already + answered, or a reloaded page — the single-use record is gone either way. */} + {requestId && consent.loadError && ( + + )} + + {consent.decisionError && ( + + )} + + {loadError && ( + + )} + + {revokeError && ( + + )} + + {isLoading ? ( +
+ +
+ ) : ( + + )} +
+ + {consent.request && ( + + )} + + !open && cancelRevoke()} + title={t('Disconnect this application?')} + description={t( + 'It will immediately lose access to OpenOps and will have to be authorized again to reconnect.', + )} + confirmButtonText={t('Disconnect')} + confirmButtonVariant="destructive" + onConfirm={confirmRevoke} + onCancel={cancelRevoke} + /> +
+ ); +}; + +ConnectedAppsPage.displayName = 'ConnectedAppsPage'; +export { ConnectedAppsPage }; diff --git a/packages/react-ui/src/app/routes/settings/connected-apps/index.tsx b/packages/react-ui/src/app/routes/settings/connected-apps/index.tsx new file mode 100644 index 0000000000..ee81a9487f --- /dev/null +++ b/packages/react-ui/src/app/routes/settings/connected-apps/index.tsx @@ -0,0 +1 @@ +export { ConnectedAppsPage as default } from './connected-apps-page'; diff --git a/packages/server/api/src/app/ai/mcp/openops-tools.ts b/packages/server/api/src/app/ai/mcp/openops-tools.ts index 64fead52ef..bc00062085 100644 --- a/packages/server/api/src/app/ai/mcp/openops-tools.ts +++ b/packages/server/api/src/app/ai/mcp/openops-tools.ts @@ -2,6 +2,7 @@ import { createMCPClient } from '@ai-sdk/mcp'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { AppSystemProp, + logger, networkUtls, SharedSystemProp, system, @@ -33,38 +34,51 @@ const INCLUDED_PATHS: Record = { '/v1/app-connections/metadata': ['get'], }; -function filterOpenApiSchema(schema: OpenAPI.Document): OpenAPI.Document { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const filteredPaths: Record = {}; +/** + * The MCP server takes its allow-list as a file and reads the OpenAPI document from + * the API itself, so this writes `INCLUDED_PATHS` out in the shape it expects. Writing + * it rather than shipping a copy alongside the MCP server keeps this the only place + * the chat's exposed surface is declared. + * + * Entries the running API does not serve are dropped, which is what the old schema + * filter did implicitly. It matters more now: the MCP server refuses to start on an + * operation it cannot find, so passing a stale entry through would cost every tool + * rather than the one that drifted. + */ +function buildRouteList(schema: OpenAPI.Document): string { + const available = schema.paths ?? {}; - for (const [path, pathItem] of Object.entries(schema.paths ?? {})) { - if (!INCLUDED_PATHS[path]) continue; + const routes = Object.entries(INCLUDED_PATHS) + .map(([path, methods]) => { + const pathItem = available[path]; + const served = pathItem + ? methods.filter((method) => method in pathItem) + : []; - filteredPaths[path] = {}; - for (const [method, op] of Object.entries(pathItem)) { - if (INCLUDED_PATHS[path].includes(method.toLowerCase())) { - filteredPaths[path][method] = op; + if (served.length !== methods.length) { + logger.warn('Skipping MCP operations the API does not expose', { + path, + requested: methods, + served, + }); } - } - } - return { ...schema, paths: filteredPaths }; + return { path, methods: served }; + }) + .filter((route) => route.methods.length > 0); + + return JSON.stringify({ routes }); } -let cachedSchemaPath: string | undefined; +let cachedRoutesPath: string | undefined; -async function getOpenApiSchemaPath(app: FastifyInstance): Promise { - if (!cachedSchemaPath) { - const openApiSchema = app.swagger(); - const filteredSchema = filterOpenApiSchema(openApiSchema); - cachedSchemaPath = path.join(os.tmpdir(), 'openapi-schema.json'); - await fs.writeFile( - cachedSchemaPath, - JSON.stringify(filteredSchema), - 'utf-8', - ); +async function getRouteListPath(app: FastifyInstance): Promise { + if (!cachedRoutesPath) { + const routesPath = path.join(os.tmpdir(), 'openops-mcp-routes.json'); + await fs.writeFile(routesPath, buildRouteList(app.swagger()), 'utf-8'); + cachedRoutesPath = routesPath; } - return cachedSchemaPath; + return cachedRoutesPath; } export async function getOpenOpsTools( @@ -78,7 +92,7 @@ export async function getOpenOpsTools( const pythonPath = path.join(basePath, '.venv', 'bin', 'python'); const serverPath = path.join(basePath, 'main.py'); - const tempSchemaPath = await getOpenApiSchemaPath(app); + const routesPath = await getRouteListPath(app); const serviceToken = await accessTokenManager.generateServiceToken( userAuthToken, @@ -89,9 +103,12 @@ export async function getOpenOpsTools( command: pythonPath, args: [serverPath], env: { - OPENAPI_SCHEMA_PATH: tempSchemaPath, + // stdio: the server acts as one service principal, so the token is passed + // in rather than obtained per request as it is over HTTP. + MCP_TRANSPORT: 'stdio', AUTH_TOKEN: serviceToken, - API_BASE_URL: networkUtls.getInternalApiUrl(), + OPENOPS_MCP_ROUTES: routesPath, + OPENOPS_API_URL: networkUtls.getInternalApiUrl(), OPENOPS_MCP_SERVER_PATH: basePath, LOGZIO_TOKEN: system.get(SharedSystemProp.LOGZIO_TOKEN) ?? '', ENVIRONMENT: diff --git a/packages/server/api/src/app/app.ts b/packages/server/api/src/app/app.ts index fd677eeee5..f5686552e6 100644 --- a/packages/server/api/src/app/app.ts +++ b/packages/server/api/src/app/app.ts @@ -53,6 +53,9 @@ import { formModule } from './flows/flow/form/form.module'; import { folderModule } from './flows/folder/folder.module'; import { triggerEventModule } from './flows/trigger-events/trigger-event.module'; import { systemJobsSchedule } from './helper/system-jobs'; +import { registerOAuthCleanupHandler } from './oauth/oauth-cleanup-job'; +import { oauthConfig } from './oauth/oauth-config'; +import { oauthModule } from './oauth/oauth.module'; import { organizationModule } from './organization/organization.module'; import { projectModule } from './project/project-module'; import { slackInteractionModule } from './slack/slack-interaction-module'; @@ -225,6 +228,14 @@ export const setupApp = async ( await app.register(blockVariableModule); await app.register(benchmarkModule); + // Unconditional: the cleanup schedule lives in Redis and survives OAuth being turned + // back off, so the handler has to exist even then. It no-ops while disabled. + registerOAuthCleanupHandler(); + + if (oauthConfig.isEnabled()) { + await app.register(oauthModule); + } + app.get( '/redirect', async ( diff --git a/packages/server/api/src/app/authentication/context/access-token-manager.ts b/packages/server/api/src/app/authentication/context/access-token-manager.ts index c5b0c1c156..727e90dc06 100644 --- a/packages/server/api/src/app/authentication/context/access-token-manager.ts +++ b/packages/server/api/src/app/authentication/context/access-token-manager.ts @@ -12,8 +12,14 @@ import { WorkerMachineType, WorkerPrincipal, } from '@openops/shared'; +import jwtLibrary from 'jsonwebtoken'; import { nanoid } from 'nanoid'; -import { jwtUtils } from '../../helper/jwt-utils'; +import { JwtSignAlgorithm, jwtUtils } from '../../helper/jwt-utils'; +import { oauthConfig } from '../../oauth/oauth-config'; +import { OAuthError } from '../../oauth/oauth-errors'; +import { OAuthAccessTokenClaims } from '../../oauth/oauth-model'; +import { buildOAuthServicePrincipal } from '../../oauth/service-principal'; +import { signingKeyService } from '../../oauth/signing-key.service'; const openOpsRefreshTokenLifetimeSeconds = (system.getNumber(AppSystemProp.JWT_TOKEN_LIFETIME_HOURS) ?? 168) * 3600; @@ -111,6 +117,10 @@ export const accessTokenManager = { }, async extractPrincipal(token: string): Promise { + if (isOAuthIssuedToken(token)) { + return extractOAuthPrincipal(token); + } + const secret = await jwtUtils.getJwtSecret(); try { @@ -133,6 +143,64 @@ export const accessTokenManager = { }, }; +/** + * Internal tokens (sessions, engine, worker, service) are always signed with the + * shared HS256 secret; OAuth-issued tokens are the only RS256 ones. Dispatching + * on the algorithm keeps the two trust domains separate — neither key can be + * used to forge a token belonging to the other. + */ +function isOAuthIssuedToken(token: string): boolean { + return ( + jwtLibrary.decode(token, { complete: true })?.header?.alg === + JwtSignAlgorithm.RS256 + ); +} + +/** + * Verification happens here rather than in each route so no caller can skip the + * audience check. Only tokens minted for the API audience authenticate against + * the API: a token issued for the MCP resource server is rejected everywhere, + * including on paths that call `extractPrincipal` directly, such as websockets. + */ +async function extractOAuthPrincipal(token: string): Promise { + const invalidToken = new ApplicationError({ + code: ErrorCode.INVALID_BEARER_TOKEN, + params: { + message: 'invalid access token', + }, + }); + + if (!oauthConfig.isEnabled()) { + throw invalidToken; + } + + try { + const claims = await signingKeyService.verifyAccessToken( + token, + oauthConfig.getApiAudience(), + ); + + return await buildOAuthServicePrincipal( + claims as unknown as OAuthAccessTokenClaims, + ); + } catch (error) { + // Only a verdict about the token itself becomes a 401. A database outage or + // any other server-side failure must not be reported as "your credential is + // invalid": OAuth clients respond to that by discarding their refresh token + // and re-running authorization, turning a brief blip into a re-consent storm. + if (error instanceof OAuthError && error.statusCode < 500) { + logger.info('Rejected OAuth access token', { + error: error.errorCode, + description: error.description, + }); + throw invalidToken; + } + + logger.error('OAuth authentication failed for a non-token reason', error); + throw error; + } +} + type GenerateEngineTokenParams = { projectId: ProjectId; queueToken?: string; diff --git a/packages/server/api/src/app/core/security/authn/access-token-authn-handler.ts b/packages/server/api/src/app/core/security/authn/access-token-authn-handler.ts index 31b8bd3f94..1cad4131fc 100644 --- a/packages/server/api/src/app/core/security/authn/access-token-authn-handler.ts +++ b/packages/server/api/src/app/core/security/authn/access-token-authn-handler.ts @@ -24,17 +24,23 @@ export class AccessTokenAuthnHandler extends BaseSecurityHandler { return Promise.resolve(hasToken || !publicRoute); } + /** + * An explicit `Authorization` header wins over the session cookie. A caller + * that presents a bearer token is stating which identity it wants to act as, + * and silently preferring an ambient cookie would authenticate it as somebody + * else — including with a different token audience. + */ private getAccessToken(request: FastifyRequest): string | undefined { - const cookieToken = request.cookies?.[AccessTokenAuthnHandler.COOKIE_NAME]; - if (!isNil(cookieToken)) { - return cookieToken; - } - const header = request.headers[AccessTokenAuthnHandler.HEADER_NAME]; if (header?.startsWith(AccessTokenAuthnHandler.HEADER_PREFIX)) { return header.substring(AccessTokenAuthnHandler.HEADER_PREFIX.length); } + const cookieToken = request.cookies?.[AccessTokenAuthnHandler.COOKIE_NAME]; + if (!isNil(cookieToken)) { + return cookieToken; + } + return undefined; } diff --git a/packages/server/api/src/app/database/database-connection.ts b/packages/server/api/src/app/database/database-connection.ts index 5b53de6e87..74846106ff 100644 --- a/packages/server/api/src/app/database/database-connection.ts +++ b/packages/server/api/src/app/database/database-connection.ts @@ -23,6 +23,14 @@ import { FlowEntity } from '../flows/flow/flow.entity'; import { FolderEntity } from '../flows/folder/folder.entity'; import { FlowStepTestOutputEntity } from '../flows/step-test-output/flow-step-test-output-entity'; import { TriggerEventEntity } from '../flows/trigger-events/trigger-event.entity'; +import { + OAuthAuthorizationCodeEntity, + OAuthClientEntity, + OAuthGrantEntity, + OAuthPendingAuthorizationEntity, + OAuthRefreshTokenEntity, + OAuthSigningKeyEntity, +} from '../oauth/oauth.entity'; import { OrganizationEntity } from '../organization/organization.entity'; import { ProjectEntity } from '../project/project-entity'; import { StoreEntryEntity } from '../store-entry/store-entry-entity'; @@ -60,6 +68,12 @@ function getEntities(): EntitySchema[] { AiConfigEntity, McpConfigEntity, FlowStepTestOutputEntity, + OAuthSigningKeyEntity, + OAuthClientEntity, + OAuthGrantEntity, + OAuthPendingAuthorizationEntity, + OAuthAuthorizationCodeEntity, + OAuthRefreshTokenEntity, ]; return entities; diff --git a/packages/server/api/src/app/database/migrations/1785312000000-CreateOAuthTables.ts b/packages/server/api/src/app/database/migrations/1785312000000-CreateOAuthTables.ts new file mode 100644 index 0000000000..fa2a315186 --- /dev/null +++ b/packages/server/api/src/app/database/migrations/1785312000000-CreateOAuthTables.ts @@ -0,0 +1,179 @@ +import { logger } from '@openops/server-shared'; +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateOAuthTables1785312000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + logger.info('CreateOAuthTables1785312000000: starting'); + + await queryRunner.query(` + CREATE TABLE "oauth_signing_key" ( + "id" varchar(21) NOT NULL, + "created" timestamp with time zone DEFAULT now() NOT NULL, + "updated" timestamp with time zone DEFAULT now() NOT NULL, + "privateKeyEncrypted" text NOT NULL, + "publicKeyPem" text NOT NULL, + "status" varchar(16) NOT NULL, + CONSTRAINT "PK_oauth_signing_key" PRIMARY KEY ("id") + ); + `); + + // Guarantees concurrently booting replicas converge on a single active key. + await queryRunner.query(` + CREATE UNIQUE INDEX "idx_oauth_signing_key_single_active" + ON "oauth_signing_key" ("status") WHERE "status" = 'active'; + `); + + await queryRunner.query(` + CREATE TABLE "oauth_client" ( + "id" varchar(21) NOT NULL, + "created" timestamp with time zone DEFAULT now() NOT NULL, + "updated" timestamp with time zone DEFAULT now() NOT NULL, + "clientName" varchar(128) NOT NULL, + "redirectUris" jsonb NOT NULL, + "grantTypes" jsonb NOT NULL, + "tokenEndpointAuthMethod" varchar(32) NOT NULL, + "clientSecretHash" varchar(64), + CONSTRAINT "PK_oauth_client" PRIMARY KEY ("id") + ); + `); + + await queryRunner.query(` + CREATE TABLE "oauth_grant" ( + "id" varchar(21) NOT NULL, + "created" timestamp with time zone DEFAULT now() NOT NULL, + "updated" timestamp with time zone DEFAULT now() NOT NULL, + "clientId" varchar(21) NOT NULL, + "userId" varchar(21) NOT NULL, + "resourceId" varchar(32) NOT NULL, + "status" varchar(16) NOT NULL, + "lastUsedAt" timestamp with time zone, + "revokedAt" timestamp with time zone, + CONSTRAINT "PK_oauth_grant" PRIMARY KEY ("id"), + CONSTRAINT "fk_oauth_grant_client" FOREIGN KEY ("clientId") + REFERENCES "oauth_client" ("id") ON DELETE CASCADE, + CONSTRAINT "fk_oauth_grant_user" FOREIGN KEY ("userId") + REFERENCES "user" ("id") ON DELETE CASCADE + ); + `); + + // Not unique: a user may hold several connections for the same client, each + // from its own authorization and revocable on its own. + await queryRunner.query(` + CREATE INDEX "idx_oauth_grant_client_id_user_id" + ON "oauth_grant" ("clientId", "userId"); + `); + + await queryRunner.query(` + CREATE INDEX "idx_oauth_grant_user_id" ON "oauth_grant" ("userId"); + `); + + await queryRunner.query(` + CREATE TABLE "oauth_pending_authorization" ( + "id" varchar(21) NOT NULL, + "created" timestamp with time zone DEFAULT now() NOT NULL, + "updated" timestamp with time zone DEFAULT now() NOT NULL, + "clientId" varchar(21) NOT NULL, + "redirectUri" varchar(512) NOT NULL, + "codeChallenge" varchar(43) NOT NULL, + "resource" varchar(512) NOT NULL, + "scope" varchar(128) NOT NULL, + "state" text, + "expiresAt" timestamp with time zone NOT NULL, + "consumedAt" timestamp with time zone, + CONSTRAINT "PK_oauth_pending_authorization" PRIMARY KEY ("id"), + CONSTRAINT "fk_oauth_pending_authorization_client" FOREIGN KEY ("clientId") + REFERENCES "oauth_client" ("id") ON DELETE CASCADE + ); + `); + + await queryRunner.query(` + CREATE INDEX "idx_oauth_pending_authorization_expires_at" + ON "oauth_pending_authorization" ("expiresAt"); + `); + + await queryRunner.query(` + CREATE TABLE "oauth_authorization_code" ( + "id" varchar(21) NOT NULL, + "created" timestamp with time zone DEFAULT now() NOT NULL, + "updated" timestamp with time zone DEFAULT now() NOT NULL, + "codeHash" varchar(64) NOT NULL, + "clientId" varchar(21) NOT NULL, + "userId" varchar(21) NOT NULL, + "redirectUri" varchar(512) NOT NULL, + "codeChallenge" varchar(43) NOT NULL, + "resource" varchar(512) NOT NULL, + "scope" varchar(128) NOT NULL, + "expiresAt" timestamp with time zone NOT NULL, + "consumedAt" timestamp with time zone, + CONSTRAINT "PK_oauth_authorization_code" PRIMARY KEY ("id"), + CONSTRAINT "fk_oauth_authorization_code_client" FOREIGN KEY ("clientId") + REFERENCES "oauth_client" ("id") ON DELETE CASCADE + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX "idx_oauth_authorization_code_code_hash" + ON "oauth_authorization_code" ("codeHash"); + `); + + await queryRunner.query(` + CREATE INDEX "idx_oauth_authorization_code_expires_at" + ON "oauth_authorization_code" ("expiresAt"); + `); + + await queryRunner.query(` + CREATE TABLE "oauth_refresh_token" ( + "id" varchar(21) NOT NULL, + "created" timestamp with time zone DEFAULT now() NOT NULL, + "updated" timestamp with time zone DEFAULT now() NOT NULL, + "tokenHash" varchar(64) NOT NULL, + "grantId" varchar(21) NOT NULL, + "familyId" varchar(21) NOT NULL, + "clientId" varchar(21) NOT NULL, + "resource" varchar(512) NOT NULL, + "scope" varchar(128) NOT NULL, + "projectId" varchar(21) NOT NULL, + "expiresAt" timestamp with time zone NOT NULL, + "revokedAt" timestamp with time zone, + CONSTRAINT "PK_oauth_refresh_token" PRIMARY KEY ("id"), + CONSTRAINT "fk_oauth_refresh_token_grant" FOREIGN KEY ("grantId") + REFERENCES "oauth_grant" ("id") ON DELETE CASCADE, + CONSTRAINT "fk_oauth_refresh_token_client" FOREIGN KEY ("clientId") + REFERENCES "oauth_client" ("id") ON DELETE CASCADE + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX "idx_oauth_refresh_token_token_hash" + ON "oauth_refresh_token" ("tokenHash"); + `); + + await queryRunner.query(` + CREATE INDEX "idx_oauth_refresh_token_grant_id" + ON "oauth_refresh_token" ("grantId"); + `); + + await queryRunner.query(` + CREATE INDEX "idx_oauth_refresh_token_family_id" + ON "oauth_refresh_token" ("familyId"); + `); + + await queryRunner.query(` + CREATE INDEX "idx_oauth_refresh_token_expires_at" + ON "oauth_refresh_token" ("expiresAt"); + `); + + logger.info('CreateOAuthTables1785312000000: completed'); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "oauth_refresh_token";`); + await queryRunner.query(`DROP TABLE IF EXISTS "oauth_authorization_code";`); + await queryRunner.query( + `DROP TABLE IF EXISTS "oauth_pending_authorization";`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS "oauth_grant";`); + await queryRunner.query(`DROP TABLE IF EXISTS "oauth_client";`); + await queryRunner.query(`DROP TABLE IF EXISTS "oauth_signing_key";`); + } +} diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index be7f6eb346..8b1e87a2fd 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -39,6 +39,7 @@ import { AddBenchmarkAndBenchmarkFlowTables1770297289194 } from './migrations/17 import { DropLastRunIdFromBenchmark1772449919844 } from './migrations/1772449919844-DropLastRunIdFromBenchmark'; import { AddIsCleanupToBenchmarkFlow1773046640936 } from './migrations/1773046640936-AddIsCleanupToBenchmarkFlow'; import { FixFolderUniqueConstraint1776097737024 } from './migrations/1776097737024-FixFolderUniqueConstraint'; +import { CreateOAuthTables1785312000000 } from './migrations/1785312000000-CreateOAuthTables'; const getSslConfig = (): boolean | TlsOptions => { const useSsl = system.get(AppSystemProp.POSTGRES_USE_SSL); @@ -90,6 +91,7 @@ const getMigrations = (): (new () => MigrationInterface)[] => { DropLastRunIdFromBenchmark1772449919844, AddIsCleanupToBenchmarkFlow1773046640936, FixFolderUniqueConstraint1776097737024, + CreateOAuthTables1785312000000, ]; }; diff --git a/packages/server/api/src/app/flags/flag.service.ts b/packages/server/api/src/app/flags/flag.service.ts index f17e8baec1..9178180212 100644 --- a/packages/server/api/src/app/flags/flag.service.ts +++ b/packages/server/api/src/app/flags/flag.service.ts @@ -10,6 +10,7 @@ import { Flag, FlagId } from '@openops/shared'; import axios from 'axios'; import { webhookUtils } from 'server-worker'; import { repoFactory } from '../core/db/repo-factory'; +import { oauthConfig } from '../oauth/oauth-config'; import { devFlagsService } from './dev-flags.service'; import { FlagEntity } from './flag.entity'; import { defaultTheme } from './theme'; @@ -277,6 +278,15 @@ export const flagService = { created, updated, }, + { + // Whether external applications can connect at all. With OAuth off, every + // /v1/oauth route 404s, so the UI that manages those connections has nothing + // to show and is hidden. + id: FlagId.CONNECTED_APPS_ENABLED, + value: oauthConfig.isEnabled(), + created, + updated, + }, { id: FlagId.THIRD_PARTY_AUTH_PROVIDER_REDIRECT_URL, value: await this.getBackendRedirectUrl(), diff --git a/packages/server/api/src/app/helper/system-jobs/common.ts b/packages/server/api/src/app/helper/system-jobs/common.ts index 954560b3c6..c41affd430 100644 --- a/packages/server/api/src/app/helper/system-jobs/common.ts +++ b/packages/server/api/src/app/helper/system-jobs/common.ts @@ -15,6 +15,7 @@ export enum SystemJobName { CREATE_TEMPLATE_TABLES = 'create-template-tables', CAMPAIGN_COMPLETION = 'campaign-completion', CONNECTION_VALIDATION = 'connection-validation', + OAUTH_CLEANUP = 'oauth-cleanup', } type HardDeleteProjectSystemJobData = { @@ -44,6 +45,7 @@ type SystemJobDataMap = { [SystemJobName.LOGS_CLEANUP_TRIGGER]: Record; [SystemJobName.CREATE_TEMPLATE_TABLES]: TablesServerContext; [SystemJobName.CONNECTION_VALIDATION]: undefined; + [SystemJobName.OAUTH_CLEANUP]: Record; }; export type SystemJobData = diff --git a/packages/server/api/src/app/oauth/authorize-validation.ts b/packages/server/api/src/app/oauth/authorize-validation.ts new file mode 100644 index 0000000000..05e5e10e88 --- /dev/null +++ b/packages/server/api/src/app/oauth/authorize-validation.ts @@ -0,0 +1,232 @@ +import { invalidRequest } from './oauth-errors'; +import { OAuthClient } from './oauth-model'; +import { isValidCodeChallenge } from './pkce'; +import { matchesRegisteredRedirectUri } from './redirect-uri'; +import { RegisteredResource, resolveResource } from './resource-registry'; + +/** + * Query parameters arrive unvalidated, and a form-encoded parser can turn + * `state[x]=1` into an object, so every field is read through {@link readParam} + * rather than assumed to be a string. + */ +export type AuthorizeQuery = Record; + +/** Same reasoning for form-encoded bodies on the token and revocation endpoints. */ +export type OAuthRequestBody = Record; + +/** + * `state` is opaque client data that has to round-trip byte for byte — clients + * legitimately put signed blobs in it — so it is stored unbounded and capped only + * to keep a single request from being used to write arbitrary amounts. + */ +const MAX_STATE_LENGTH = 2048; + +export function readParam( + query: AuthorizeQuery, + name: string, +): string | undefined { + const value = query[name]; + return typeof value === 'string' ? value : undefined; +} + +/** + * A query or form parser turns `scope[x]=1` into an object. Such a value is + * malformed input, not an omission: substituting a default for it would give the + * client something other than what it asked for without telling it. + */ +function findMalformedParam(query: AuthorizeQuery): string | undefined { + return Object.keys(query).find( + (name) => query[name] !== undefined && typeof query[name] !== 'string', + ); +} + +export type AuthorizeValidationResult = + /** The redirect target cannot be trusted; show the error instead. */ + | { kind: 'render_error'; error: string; description: string } + /** + * The client and its redirect_uri are known good, so the error belongs back at + * the client. Carries the validated destination so the caller never re-reads + * the raw query to build it. + */ + | { + kind: 'redirect_error'; + error: string; + description: string; + redirectUri: string; + state: string | null; + } + | { + kind: 'ok'; + resource: RegisteredResource; + scope: string; + redirectUri: string; + codeChallenge: string; + state: string | null; + }; + +/** + * Validates an authorize request once, up front, and returns the validated values + * so nothing downstream re-reads the raw query. + * + * Callers must not redirect for a `render_error`: an unknown client or an + * unregistered redirect_uri means the supplied redirect target cannot be trusted, + * so sending the browser there would turn this endpoint into an open redirector. + */ +export function validateAuthorizeRequest( + query: AuthorizeQuery, + client: OAuthClient | null, +): AuthorizeValidationResult { + if (!client) { + return { + kind: 'render_error', + error: 'invalid_client', + description: 'Unknown client.', + }; + } + + const redirectUri = readParam(query, 'redirect_uri'); + + if ( + !redirectUri || + !matchesRegisteredRedirectUri(client.redirectUris, redirectUri) + ) { + return { + kind: 'render_error', + error: 'invalid_request', + description: 'The redirect_uri does not match a registered value.', + }; + } + + const malformedParam = findMalformedParam(query); + + if (malformedParam !== undefined) { + return { + kind: 'redirect_error', + error: 'invalid_request', + description: `${malformedParam} must be a single string value.`, + redirectUri, + state: null, + }; + } + + const state = readParam(query, 'state'); + + if (state !== undefined && state.length > MAX_STATE_LENGTH) { + return { + kind: 'redirect_error', + error: 'invalid_request', + description: `state must be at most ${MAX_STATE_LENGTH} characters.`, + redirectUri, + state: null, + }; + } + + if (readParam(query, 'response_type') !== 'code') { + return { + kind: 'redirect_error', + error: 'unsupported_response_type', + description: 'Only the authorization code flow is supported.', + redirectUri, + state: state ?? null, + }; + } + + // PKCE is mandatory in OAuth 2.1, and only S256 is accepted. + if (readParam(query, 'code_challenge_method') !== 'S256') { + return { + kind: 'redirect_error', + error: 'invalid_request', + description: 'code_challenge_method must be S256.', + redirectUri, + state: state ?? null, + }; + } + + const codeChallenge = readParam(query, 'code_challenge'); + + if (!codeChallenge || !isValidCodeChallenge(codeChallenge)) { + return { + kind: 'redirect_error', + error: 'invalid_request', + description: 'A valid S256 code_challenge is required.', + redirectUri, + state: state ?? null, + }; + } + + const requestedResource = readParam(query, 'resource'); + + if (!requestedResource) { + return { + kind: 'redirect_error', + error: 'invalid_target', + description: 'The resource parameter is required.', + redirectUri, + state: state ?? null, + }; + } + + const resource = resolveResource(requestedResource); + + if (!resource) { + return { + kind: 'redirect_error', + error: 'invalid_target', + description: 'Unknown resource.', + redirectUri, + state: state ?? null, + }; + } + + // De-duplicated because a repeated scope passes the subset check below while + // inflating the stored value without limit. + const requestedScopes = [ + ...new Set( + (readParam(query, 'scope') ?? resource.scopes.join(' ')) + .split(' ') + .filter((scope) => scope.length > 0), + ), + ]; + + if (!requestedScopes.every((scope) => resource.scopes.includes(scope))) { + return { + kind: 'redirect_error', + error: 'invalid_scope', + description: 'The requested scope is not available for this resource.', + redirectUri, + state: state ?? null, + }; + } + + return { + kind: 'ok', + resource, + scope: requestedScopes.join(' '), + redirectUri, + codeChallenge, + state: state ?? null, + }; +} + +/** + * The form-encoded body is parsed with `qs`, so `code[x]=1` arrives as an object. + * Requiring an actual string keeps a malformed value from reaching code that + * expects one and surfacing as a 500 rather than an RFC 6749 error. + */ +export function requireParam(body: OAuthRequestBody, name: string): string { + const value = body[name]; + + if (typeof value !== 'string' || value.length === 0) { + throw invalidRequest(`${name} is required`); + } + + return value; +} + +export function optionalParam( + body: OAuthRequestBody, + name: string, +): string | undefined { + const value = body[name]; + return typeof value === 'string' ? value : undefined; +} diff --git a/packages/server/api/src/app/oauth/available-projects.ts b/packages/server/api/src/app/oauth/available-projects.ts new file mode 100644 index 0000000000..8b359f6a33 --- /dev/null +++ b/packages/server/api/src/app/oauth/available-projects.ts @@ -0,0 +1,39 @@ +import { isNil } from '@openops/shared'; +import { projectService } from '../project/project-service'; +import { userService } from '../user/user-service'; +import { getOAuthProjectMembershipService } from './project-membership-factory'; + +export type AvailableProject = { + projectId: string; + projectName: string; +}; + +/** + * Every project the user may act in, for a client deciding where to switch to. + * + * Names are resolved here rather than by the membership service because the seam + * answers questions about authority, not about display. + */ +export async function listAvailableProjects( + userId: string, +): Promise { + const user = await userService.get({ id: userId }); + + if (isNil(user)) { + return []; + } + + const memberships = await getOAuthProjectMembershipService().listForUser( + user, + ); + const projects = await Promise.all( + memberships.map((membership) => + projectService.getOne(membership.projectId), + ), + ); + + return memberships.map((membership, index) => ({ + projectId: membership.projectId, + projectName: projects[index]?.displayName ?? membership.projectId, + })); +} diff --git a/packages/server/api/src/app/oauth/canonical-url.ts b/packages/server/api/src/app/oauth/canonical-url.ts new file mode 100644 index 0000000000..13c9b18321 --- /dev/null +++ b/packages/server/api/src/app/oauth/canonical-url.ts @@ -0,0 +1,22 @@ +/** + * Trailing-slash normalization, without a regular expression. + * + * The obvious `value.replace(/\/+$/, '')` is quadratic: for a long run of slashes that + * is not at the end, the engine matches greedily from every start position and + * backtracks each time. Measured at ~145 ms for 20k slashes and ~8.8 s for 160k. + * + * That matters because one caller normalizes the client-supplied `resource` parameter on + * `/authorize` and `/token`, both public. A single request could hold the event loop for + * seconds. Scanning backwards is linear and has no such worst case. + */ +export function stripTrailingSlashes(value: string): string { + let end = value.length; + + while (end > 0 && value.charCodeAt(end - 1) === SLASH) { + end -= 1; + } + + return end === value.length ? value : value.slice(0, end); +} + +const SLASH = '/'.charCodeAt(0); diff --git a/packages/server/api/src/app/oauth/clients.service.ts b/packages/server/api/src/app/oauth/clients.service.ts new file mode 100644 index 0000000000..5d92392073 --- /dev/null +++ b/packages/server/api/src/app/oauth/clients.service.ts @@ -0,0 +1,344 @@ +import { AppSystemProp, logger } from '@openops/server-shared'; +import { ApplicationError, ErrorCode, openOpsId } from '@openops/shared'; +import { repoFactory } from '../core/db/repo-factory'; +import { oauthConfig } from './oauth-config'; +import { sha256Hex, timingSafeStringEqual } from './oauth-crypto'; +import { + invalidClient, + invalidClientMetadata, + invalidRedirectUri, + unauthorizedClient, +} from './oauth-errors'; +import { OAuthClient, OAuthTokenEndpointAuthMethod } from './oauth-model'; +import { OAuthClientEntity } from './oauth.entity'; +import { isRegistrableRedirectUri } from './redirect-uri'; + +const repo = repoFactory(OAuthClientEntity); + +/** + * Row id for the hosted MCP resource server. Doubles as the `client_id` it sends + * on the token endpoint, so it must fit the 21-character id column. + */ +export const RS_CLIENT_ID = 'openops-mcp-rs'; +export const TOKEN_EXCHANGE_GRANT = + 'urn:ietf:params:oauth:grant-type:token-exchange'; + +const RS_CLIENT_NAME = 'OpenOps MCP Resource Server'; +const RS_CLIENT_SECRET_MIN_LENGTH = 32; +const UNIQUE_VIOLATION = '23505'; + +/** No SHA-256 hex digest equals this, so comparing against it always fails. */ +const UNMATCHABLE_HASH = '-'.repeat(64); + +/** + * Grants a dynamically registered client may ask for. Deliberately excludes + * `implicit`, `password`, `client_credentials` and the token-exchange grant: + * anyone on the network can register, so registration must never be a path to a + * grant that skips user consent. + */ +const REGISTRABLE_GRANT_TYPES = ['authorization_code', 'refresh_token']; + +const MAX_CLIENT_NAME_LENGTH = 128; +const MAX_REDIRECT_URIS = 10; + +export type RegisteredClientResponse = { + client_id: string; + client_name: string; + redirect_uris: string[]; + grant_types: string[]; + token_endpoint_auth_method: OAuthTokenEndpointAuthMethod; + client_id_issued_at: number; +}; + +type ClientRegistrationMetadata = { + clientName: string; + redirectUris: string[]; + grantTypes: string[]; +}; + +function parseClientName(value: unknown): string { + if (typeof value !== 'string' || value.length === 0) { + throw invalidClientMetadata('client_name is required'); + } + + if (value.length > MAX_CLIENT_NAME_LENGTH) { + throw invalidClientMetadata( + `client_name must be at most ${MAX_CLIENT_NAME_LENGTH} characters`, + ); + } + + return value; +} + +function parseRedirectUris(value: unknown): string[] { + if (!Array.isArray(value) || value.length === 0) { + throw invalidRedirectUri('redirect_uris must contain at least one entry'); + } + + if (value.length > MAX_REDIRECT_URIS) { + throw invalidRedirectUri( + `redirect_uris must contain at most ${MAX_REDIRECT_URIS} entries`, + ); + } + + for (const uri of value) { + if (typeof uri !== 'string' || !isRegistrableRedirectUri(uri)) { + throw invalidRedirectUri( + 'redirect_uris must be https URIs or http loopback URIs without a fragment', + ); + } + } + + return value as string[]; +} + +function parseGrantTypes(value: unknown): string[] { + if (value === undefined) { + return [...REGISTRABLE_GRANT_TYPES]; + } + + if (!Array.isArray(value) || value.length === 0) { + throw invalidClientMetadata('grant_types must be a non-empty array'); + } + + for (const grantType of value) { + if ( + typeof grantType !== 'string' || + !REGISTRABLE_GRANT_TYPES.includes(grantType) + ) { + throw invalidClientMetadata( + `grant_types may only contain ${REGISTRABLE_GRANT_TYPES.join(', ')}`, + ); + } + } + + return value as string[]; +} + +function assertPublicAuthMethod(value: unknown): void { + if (value !== undefined && value !== 'none') { + throw invalidClientMetadata( + 'token_endpoint_auth_method must be "none"; registered clients must use PKCE', + ); + } +} + +function parseRegistrationMetadata(body: unknown): ClientRegistrationMetadata { + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + throw invalidClientMetadata('client metadata must be a JSON object'); + } + + const metadata = body as Record; + assertPublicAuthMethod(metadata['token_endpoint_auth_method']); + + return { + clientName: parseClientName(metadata['client_name']), + redirectUris: parseRedirectUris(metadata['redirect_uris']), + grantTypes: parseGrantTypes(metadata['grant_types']), + }; +} + +/** + * RFC 6749 §2.3.1 requires both halves of the Basic credential to be + * form-urlencoded, but clients that skip the encoding are common; a malformed + * escape must therefore fall back to the raw value rather than fail decoding. + */ +function formUrlDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function parseBasicCredentials( + authorizationHeader: string | undefined, +): { clientId: string; clientSecret: string } | undefined { + if (!authorizationHeader?.toLowerCase().startsWith('basic ')) { + return undefined; + } + + const decoded = Buffer.from( + authorizationHeader.slice('basic '.length).trim(), + 'base64', + ).toString('utf-8'); + + // Only the first colon separates the halves; secrets may contain colons. + const separatorIndex = decoded.indexOf(':'); + if (separatorIndex < 0) { + return undefined; + } + + return { + clientId: formUrlDecode(decoded.slice(0, separatorIndex)), + clientSecret: formUrlDecode(decoded.slice(separatorIndex + 1)), + }; +} + +export const clientsService = { + /** RFC 7591 Dynamic Client Registration, restricted to public PKCE clients. */ + async registerClient(body: unknown): Promise { + const metadata = parseRegistrationMetadata(body); + const now = new Date().toISOString(); + + const client: OAuthClient = { + id: openOpsId(), + created: now, + updated: now, + clientName: metadata.clientName, + redirectUris: metadata.redirectUris, + grantTypes: metadata.grantTypes, + tokenEndpointAuthMethod: 'none', + clientSecretHash: null, + }; + + await repo().save(client); + logger.info('OAuth client registered', { + clientId: client.id, + clientName: client.clientName, + }); + + return { + client_id: client.id, + client_name: client.clientName, + redirect_uris: client.redirectUris, + grant_types: client.grantTypes, + token_endpoint_auth_method: client.tokenEndpointAuthMethod, + client_id_issued_at: Math.floor( + new Date(client.created).getTime() / 1000, + ), + }; + }, + + async getClient(clientId: string): Promise { + return repo().findOneBy({ id: clientId }); + }, + + async getClientOrThrow(clientId: string): Promise { + const client = await clientsService.getClient(clientId); + + if (!client) { + throw invalidClient('unknown client'); + } + + return client; + }, + + /** + * A client may only use the grants it registered, so a client that registered + * `authorization_code` alone cannot later present a refresh token. + */ + assertGrantTypeAllowed(client: OAuthClient, grantType: string): void { + if (!client.grantTypes.includes(grantType)) { + throw unauthorizedClient( + `client is not authorized to use grant type ${grantType}`, + ); + } + }, + + /** + * HTTP Basic client authentication (RFC 6749 §2.3.1) for the confidential + * resource server. Every failure returns the same description so the response + * cannot be used to enumerate client ids. + */ + async authenticateResourceServerClient( + authorizationHeader: string | undefined, + ): Promise { + const credentials = parseBasicCredentials(authorizationHeader); + + if (!credentials) { + throw invalidClient('missing client credentials'); + } + + const client = await clientsService.getClient(credentials.clientId); + const failure = invalidClient('client authentication failed'); + + const isConfidential = + client !== null && + client.tokenEndpointAuthMethod === 'client_secret_basic' && + client.clientSecretHash !== null; + + // Always run the comparison, even for an unknown client, so response time + // does not reveal whether the client id exists. + const secretMatches = timingSafeStringEqual( + sha256Hex(credentials.clientSecret), + isConfidential ? (client.clientSecretHash as string) : UNMATCHABLE_HASH, + ); + + if (!isConfidential || !secretMatches) { + logger.warn('OAuth client authentication failed', { + clientId: credentials.clientId, + reason: isConfidential + ? 'secret mismatch' + : 'not a confidential client', + }); + throw failure; + } + + return client; + }, + + /** + * Provisions the hosted MCP resource server as a confidential client on boot. + * Optional: self-hosted installs without a hosted resource server configure no + * secret and get no such client. + */ + async ensureResourceServerClient(): Promise { + const secret = oauthConfig.getResourceServerClientSecret(); + + if (!secret) { + return; + } + + // Fail at boot rather than run with a brute-forceable shared secret. This is a + // configuration fault, not an OAuth protocol response. + if (secret.length < RS_CLIENT_SECRET_MIN_LENGTH) { + throw new ApplicationError( + { + code: ErrorCode.SYSTEM_PROP_INVALID, + params: { prop: AppSystemProp.OAUTH_RS_CLIENT_SECRET }, + }, + `OPS_${AppSystemProp.OAUTH_RS_CLIENT_SECRET} must be at least ${RS_CLIENT_SECRET_MIN_LENGTH} characters`, + ); + } + + const secretHash = sha256Hex(secret); + const existing = await repo().findOneBy({ id: RS_CLIENT_ID }); + const now = new Date().toISOString(); + + if (existing) { + if (existing.clientSecretHash !== secretHash) { + await repo().update( + { id: RS_CLIENT_ID }, + { clientSecretHash: secretHash, updated: now }, + ); + logger.info('OAuth resource server client secret rotated'); + } + + return; + } + + try { + await repo().insert({ + id: RS_CLIENT_ID, + created: now, + updated: now, + clientName: RS_CLIENT_NAME, + redirectUris: [], + grantTypes: [TOKEN_EXCHANGE_GRANT], + tokenEndpointAuthMethod: 'client_secret_basic', + clientSecretHash: secretHash, + }); + logger.info('OAuth resource server client created'); + } catch (error) { + // A replica booting at the same time inserted it first; its row is + // equivalent, so adopt it rather than failing startup. + if ((error as { code?: string }).code !== UNIQUE_VIOLATION) { + throw error; + } + logger.info( + 'OAuth resource server client already created by another instance', + ); + } + }, +}; diff --git a/packages/server/api/src/app/oauth/grants.service.ts b/packages/server/api/src/app/oauth/grants.service.ts new file mode 100644 index 0000000000..3f038ad677 --- /dev/null +++ b/packages/server/api/src/app/oauth/grants.service.ts @@ -0,0 +1,231 @@ +import { logger } from '@openops/server-shared'; +import { openOpsId } from '@openops/shared'; +import { IsNull } from 'typeorm'; +import { repoFactory } from '../core/db/repo-factory'; +import { invalidGrant } from './oauth-errors'; +import { OAuthGrant, OAuthRefreshToken } from './oauth-model'; +import { OAuthGrantEntity, OAuthRefreshTokenEntity } from './oauth.entity'; + +const grantRepo = repoFactory(OAuthGrantEntity); +const refreshTokenRepo = repoFactory( + OAuthRefreshTokenEntity, +); + +const GRANT_SNAPSHOT_CACHE_TTL_MS = 60 * 1000; +const LAST_USED_WRITE_INTERVAL_MS = 60 * 1000; + +/** + * One authorized connection: a single completed authorization for one client and + * user. Everything that can revoke access keys off this row, so revoking it + * reliably kills that connection and only that connection — refresh rotation, + * token exchange and API request authentication all consult it. + */ +export type GrantSnapshot = { + id: string; + userId: string; + clientId: string; + status: OAuthGrant['status']; +}; + +type CachedSnapshot = { + snapshot: GrantSnapshot | undefined; + fetchedAt: number; +}; + +/** + * Access tokens are self-contained, so revocation is enforced by checking the + * grant on each request. The cache keeps that off the hot path while bounding + * revocation latency to the TTL. + */ +const snapshotCache = new Map(); + +/** Last time `lastUsedAt` was written, per grant, to throttle those writes. */ +const lastUsedWrittenAt = new Map(); + +/** + * Both maps are keyed by grant id and would otherwise grow for the life of the process. + * Nothing evicts an entry once its window has passed: a stale one is overwritten on the + * next read, so the key survives. Reconnecting an agent creates a *new* grant by design, + * so a fleet that reconnects on a schedule leaves a key behind every time. + * + * Sweeping on insert, and only once a map is larger than any real working set, keeps this + * off the hot path. Both maps are pure optimizations — dropping an entry costs one query + * or one `lastUsedAt` write — so clearing wholesale is safe if a sweep frees nothing. + */ +const CACHE_SWEEP_THRESHOLD = 10_000; + +function remember( + cache: Map, + key: string, + value: T, + isExpired: (entry: T) => boolean, +): void { + if (cache.size >= CACHE_SWEEP_THRESHOLD) { + for (const [existingKey, entry] of cache) { + if (isExpired(entry)) { + cache.delete(existingKey); + } + } + + // Still oversized means the entries are live, not stale. Correctness does not + // depend on them, so bound the memory rather than the query count. + if (cache.size >= CACHE_SWEEP_THRESHOLD) { + cache.clear(); + } + } + + cache.set(key, value); +} + +function toSnapshot(grant: OAuthGrant): GrantSnapshot { + return { + id: grant.id, + userId: grant.userId, + clientId: grant.clientId, + status: grant.status, + }; +} + +function invalidateSnapshot(grantId: string): void { + snapshotCache.delete(grantId); +} + +export type CreateGrantParams = { + clientId: string; + userId: string; + resourceId: string; +}; + +export const grantsService = { + /** + * Records a newly authorized connection. + * + * Every completed authorization gets its own grant, so a user can connect the + * same agent more than once and have each connection live and be revoked + * independently. Created at code redemption rather than at consent, so an + * authorization the client never completed does not appear as a connection. + */ + async create(params: CreateGrantParams): Promise { + const now = new Date().toISOString(); + + const grant: OAuthGrant = { + id: openOpsId(), + created: now, + updated: now, + clientId: params.clientId, + userId: params.userId, + resourceId: params.resourceId, + status: 'active', + lastUsedAt: null, + revokedAt: null, + }; + + await grantRepo().insert(grant); + + return grant; + }, + + async getGrantSnapshot(grantId: string): Promise { + const cached = snapshotCache.get(grantId); + if (cached && Date.now() - cached.fetchedAt < GRANT_SNAPSHOT_CACHE_TTL_MS) { + return cached.snapshot; + } + + const grant = await grantRepo().findOneBy({ id: grantId }); + const snapshot = grant ? toSnapshot(grant) : undefined; + remember( + snapshotCache, + grantId, + { snapshot, fetchedAt: Date.now() }, + (entry) => Date.now() - entry.fetchedAt >= GRANT_SNAPSHOT_CACHE_TTL_MS, + ); + + return snapshot; + }, + + async getActiveGrantOrThrow(grantId: string): Promise { + const snapshot = await grantsService.getGrantSnapshot(grantId); + + if (snapshot?.status !== 'active') { + throw invalidGrant('the authorization for this client has been revoked'); + } + + return snapshot; + }, + + /** + * Revokes one connection and every refresh token issued under it. Revoking the + * grant alone would leave the client able to mint new access tokens by + * refreshing, so the cascade is part of the same operation. Other connections + * belonging to the same user and client are untouched. + */ + async revoke(grantId: string): Promise { + const now = new Date().toISOString(); + + await grantRepo().update( + { id: grantId }, + { status: 'revoked', revokedAt: now, updated: now }, + ); + await refreshTokenRepo().update( + { grantId, revokedAt: IsNull() }, + { revokedAt: now, updated: now }, + ); + + invalidateSnapshot(grantId); + logger.info('OAuth grant revoked', { grantId }); + }, + + async revokeForUser(grantId: string, userId: string): Promise { + const grant = await grantRepo().findOneBy({ id: grantId, userId }); + + if (!grant) { + throw invalidGrant('unknown grant'); + } + + await grantsService.revoke(grantId); + }, + + async listForUser(userId: string): Promise { + return grantRepo().find({ + where: { userId, status: 'active' }, + order: { created: 'DESC' }, + }); + }, + + /** + * Records usage, which is also how a user tells their connections apart in the + * connected-apps list. Throttled because it would otherwise write on every + * single API call made through a connection. + */ + async touch(grantId: string): Promise { + const now = Date.now(); + const writtenAt = lastUsedWrittenAt.get(grantId); + + if ( + writtenAt !== undefined && + now - writtenAt < LAST_USED_WRITE_INTERVAL_MS + ) { + return; + } + + remember( + lastUsedWrittenAt, + grantId, + now, + (writtenAtEntry) => now - writtenAtEntry >= LAST_USED_WRITE_INTERVAL_MS, + ); + await grantRepo().update( + { id: grantId }, + { lastUsedAt: new Date(now).toISOString() }, + ); + }, + + clearSnapshotCacheForTests(): void { + snapshotCache.clear(); + lastUsedWrittenAt.clear(); + }, + + snapshotCacheSizeForTests(): number { + return snapshotCache.size; + }, +}; diff --git a/packages/server/api/src/app/oauth/oauth-cleanup-job.ts b/packages/server/api/src/app/oauth/oauth-cleanup-job.ts new file mode 100644 index 0000000000..b02e93a9dd --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth-cleanup-job.ts @@ -0,0 +1,149 @@ +import { logger } from '@openops/server-shared'; +import { repoFactory } from '../core/db/repo-factory'; +import { systemJobsSchedule } from '../helper/system-jobs'; +import { SystemJobName } from '../helper/system-jobs/common'; +import { systemJobHandlers } from '../helper/system-jobs/job-handlers'; +import { oauthConfig } from './oauth-config'; +import { + OAuthAuthorizationCode, + OAuthClient, + OAuthGrant, + OAuthRefreshToken, +} from './oauth-model'; +import { earlierThan } from './oauth-query'; +import { + OAuthAuthorizationCodeEntity, + OAuthClientEntity, + OAuthGrantEntity, + OAuthRefreshTokenEntity, +} from './oauth.entity'; +import { pendingAuthorizationService } from './pending-authorization.service'; + +const codeRepo = repoFactory( + OAuthAuthorizationCodeEntity, +); +const refreshTokenRepo = repoFactory( + OAuthRefreshTokenEntity, +); +const clientRepo = repoFactory(OAuthClientEntity); +const grantRepo = repoFactory(OAuthGrantEntity); + +export const OAUTH_CLEANUP_CRON = '0 * * * *'; + +/** + * Registered on every boot, including when OAuth is disabled. + * + * The schedule lives in Redis and outlives the process that created it, so a deployment + * that enabled OAuth once and later turned it off still has this job firing. Without a + * handler the worker throws `No handler for job`, and BullMQ retries — an hourly failure + * for a feature nobody is using. Registering unconditionally costs a map entry. + */ +export const registerOAuthCleanupHandler = (): void => { + systemJobHandlers.registerJobHandler( + SystemJobName.OAUTH_CLEANUP, + async (): Promise => { + if (!oauthConfig.isEnabled()) { + return; + } + + try { + await oauthCleanupJobHandler(); + } catch (error) { + // Logged rather than rethrown so one bad run does not stop the schedule. + logger.error('OAuth cleanup job failed', error); + } + }, + ); +}; + +export const scheduleOAuthCleanupJob = async (): Promise => { + await systemJobsSchedule.upsertJob({ + job: { + name: SystemJobName.OAUTH_CLEANUP, + data: {}, + }, + schedule: { + type: 'repeated', + cron: OAUTH_CLEANUP_CRON, + }, + }); +}; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Registration is open to the network, so unused clients must not accumulate. */ +const UNUSED_CLIENT_RETENTION_DAYS = 30; + +/** + * How long a dead connection stays in the connected-apps list. Each + * authorization creates its own grant, so a client that reconnects instead of + * refreshing would otherwise leave a growing trail of rows the user has to read + * past. A grant is dead once it has no usable refresh token left. + */ +const DEAD_GRANT_RETENTION_DAYS = 30; + +export const oauthCleanupJobHandler = async (): Promise => { + const now = Date.now(); + // Every cutoff is a Date, never an ISO string: see `earlierThan`. The same + // applies to the query-builder parameters below, which are bound the same way. + const nowDate = new Date(now); + const clientCutoff = new Date(now - UNUSED_CLIENT_RETENTION_DAYS * DAY_MS); + const deadGrantCutoff = new Date(now - DEAD_GRANT_RETENTION_DAYS * DAY_MS); + + const authorizationCodes = await codeRepo().delete({ + expiresAt: earlierThan(nowDate), + }); + const pendingAuthorizations = await pendingAuthorizationService.deleteExpired( + nowDate, + ); + /* + * Expiry is the only anchor, for revoked rows as much as live ones. + * + * A rotated token stays in the table until the moment it could no longer be used + * anyway, which is what lets reuse detection recognise a replay for as long as a + * replay could plausibly succeed. An independent, shorter window would mean a token + * replayed after it lapsed came back as a plain `invalid refresh token`: rejected, but + * with no family revocation and no security log line — the compromise signal lost + * precisely because the token was old. + * + * The cost is bounded by the refresh TTL, so this cannot grow without limit. + */ + const expiredRefreshTokens = await refreshTokenRepo().delete({ + expiresAt: earlierThan(nowDate), + }); + + // A `NOT EXISTS` subquery keeps this a single statement: loading every grant to + // filter in memory would not scale with the number of registered clients. The + // `none` auth method also excludes the provisioned confidential resource-server + // client, which must survive regardless of age. + const unusedClients = await clientRepo() + .createQueryBuilder() + .delete() + .where('"created" < :cutoff', { cutoff: clientCutoff }) + .andWhere('"tokenEndpointAuthMethod" = :authMethod', { authMethod: 'none' }) + .andWhere( + 'NOT EXISTS (SELECT 1 FROM oauth_grant g WHERE g."clientId" = oauth_client.id)', + ) + .execute(); + + // Runs after the refresh-token deletes above, so a grant whose tokens have just + // been cleaned up is recognised as dead in the same pass. + const deadGrants = await grantRepo() + .createQueryBuilder() + .delete() + .where('COALESCE("lastUsedAt", "created") < :cutoff', { + cutoff: deadGrantCutoff, + }) + .andWhere( + 'NOT EXISTS (SELECT 1 FROM oauth_refresh_token t WHERE t."grantId" = oauth_grant.id AND t."revokedAt" IS NULL)', + ) + .execute(); + + logger.info('OAuth cleanup completed', { + authorizationCodes: authorizationCodes.affected ?? 0, + pendingAuthorizations, + expiredRefreshTokens: expiredRefreshTokens.affected ?? 0, + unusedClients: unusedClients.affected ?? 0, + deadGrants: deadGrants.affected ?? 0, + }); +}; diff --git a/packages/server/api/src/app/oauth/oauth-config-validation.ts b/packages/server/api/src/app/oauth/oauth-config-validation.ts new file mode 100644 index 0000000000..e99399fe81 --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth-config-validation.ts @@ -0,0 +1,141 @@ +import { AppSystemProp, DatabaseType, system } from '@openops/server-shared'; +import { ApplicationError, ErrorCode } from '@openops/shared'; +import { stripTrailingSlashes } from './canonical-url'; +import { oauthConfig } from './oauth-config'; +import { getRegisteredResources } from './resource-registry'; + +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); + +function invalidProp(prop: string, message: string): ApplicationError { + return new ApplicationError( + { code: ErrorCode.SYSTEM_PROP_INVALID, params: { prop } }, + `OPS_${prop} ${message}`, + ); +} + +/** + * Scheme and host are case-insensitive per RFC 3986, and a trailing slash names + * the same resource, so audiences are compared in this form. + */ +function canonicalize(audience: string): string { + try { + const url = new URL(audience); + return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${stripTrailingSlashes( + url.pathname, + )}`; + } catch { + return audience; + } +} + +/** + * Every TTL is already required to be a number, which catches a typo but not a value that + * is merely wrong. These bounds exist because the wrong number produces a server that + * looks healthy: tokens verify, tests pass, and a guarantee is quietly gone. An + * access-token TTL of a month is the clearest case — revocation latency becomes a month, + * since a self-contained token is only re-checked when it expires. + */ +function assertWithinRange( + prop: string, + value: number, + min: number, + max: number, + unit: string, +): void { + if (!Number.isInteger(value) || value < min || value > max) { + throw invalidProp( + prop, + `must be a whole number of ${unit} between ${min} and ${max}, got ${value}`, + ); + } +} + +function parseAbsoluteUrl(prop: string, value: string): URL { + let url: URL; + + try { + url = new URL(value); + } catch { + throw invalidProp(prop, 'must be an absolute URL'); + } + + if (url.protocol !== 'https:' && !LOOPBACK_HOSTNAMES.has(url.hostname)) { + throw invalidProp(prop, 'must use https unless it points at loopback'); + } + + if (url.search !== '' || url.hash !== '') { + throw invalidProp(prop, 'must not contain a query string or fragment'); + } + + return url; +} + +/** + * Run before any OAuth route is served. Every check here exists because the + * misconfiguration it catches would otherwise produce a server that looks healthy: + * tokens verify, tests pass, and the guarantee is quietly gone. + */ +export function validateOAuthConfiguration(): void { + // The migration is registered for Postgres only, so on any other driver the + // tables are missing and the first request would fail instead of the boot. + if (system.get(AppSystemProp.DB_TYPE) === DatabaseType.SQLITE3) { + throw invalidProp( + AppSystemProp.OAUTH_ENABLED, + 'requires a PostgreSQL database', + ); + } + + parseAbsoluteUrl(AppSystemProp.OAUTH_ISSUER_URL, oauthConfig.getIssuerUrl()); + + // An access token is self-contained, so its TTL is the worst case for how long a + // revoked connection keeps working. An hour is already generous for that. + assertWithinRange( + AppSystemProp.OAUTH_ACCESS_TOKEN_TTL_SECONDS, + oauthConfig.getAccessTokenTtlSeconds(), + 60, + 60 * 60, + 'seconds', + ); + + // The exchanged token only has to outlive one API call made on an agent's behalf. + assertWithinRange( + AppSystemProp.OAUTH_EXCHANGE_TOKEN_TTL_SECONDS, + oauthConfig.getExchangeTokenTtlSeconds(), + 60, + 15 * 60, + 'seconds', + ); + + // Refresh tokens rotate, so a long life is reasonable; unbounded is not, because it + // also sets how long a revoked row must be retained for reuse detection. + assertWithinRange( + AppSystemProp.OAUTH_REFRESH_TOKEN_TTL_DAYS, + oauthConfig.getRefreshTokenTtlDays(), + 1, + 90, + 'days', + ); + + const mcpResourceUrl = oauthConfig.getMcpResourceUrl(); + if (mcpResourceUrl !== undefined) { + parseAbsoluteUrl(AppSystemProp.MCP_RESOURCE_URL, mcpResourceUrl); + } + + // Distinct audiences are what separate a token the resource server may hold + // from one the API will accept. Were they equal, the resource server would + // accept API-audience tokens and the no-token-passthrough rule — the whole + // reason for a separate signing domain — would silently not hold. + // + // Compared in canonical form rather than as raw strings, so this holds no matter + // how the values were normalised on the way in. + const audiences = getRegisteredResources().map((resource) => + canonicalize(resource.audience), + ); + + if (new Set(audiences).size !== audiences.length) { + throw invalidProp( + AppSystemProp.MCP_RESOURCE_URL, + 'must differ from OPS_OAUTH_ISSUER_URL: each resource needs its own audience', + ); + } +} diff --git a/packages/server/api/src/app/oauth/oauth-config.ts b/packages/server/api/src/app/oauth/oauth-config.ts new file mode 100644 index 0000000000..c886d52263 --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth-config.ts @@ -0,0 +1,39 @@ +import { AppSystemProp, system } from '@openops/server-shared'; +import { stripTrailingSlashes } from './canonical-url'; + +export const oauthConfig = { + isEnabled(): boolean { + return system.getBoolean(AppSystemProp.OAUTH_ENABLED) ?? false; + }, + getIssuerUrl(): string { + return stripTrailingSlashes( + system.getOrThrow(AppSystemProp.OAUTH_ISSUER_URL), + ); + }, + getApiAudience(): string { + return oauthConfig.getIssuerUrl(); + }, + getMcpResourceUrl(): string | undefined { + const value = system.get(AppSystemProp.MCP_RESOURCE_URL); + return value ? stripTrailingSlashes(value) : undefined; + }, + getAccessTokenTtlSeconds(): number { + return system.getNumberOrThrow( + AppSystemProp.OAUTH_ACCESS_TOKEN_TTL_SECONDS, + ); + }, + getRefreshTokenTtlDays(): number { + return system.getNumberOrThrow(AppSystemProp.OAUTH_REFRESH_TOKEN_TTL_DAYS); + }, + getExchangeTokenTtlSeconds(): number { + return system.getNumberOrThrow( + AppSystemProp.OAUTH_EXCHANGE_TOKEN_TTL_SECONDS, + ); + }, + getSigningKeyPemPath(): string | undefined { + return system.get(AppSystemProp.OAUTH_SIGNING_KEY_PEM_PATH); + }, + getResourceServerClientSecret(): string | undefined { + return system.get(AppSystemProp.OAUTH_RS_CLIENT_SECRET); + }, +}; diff --git a/packages/server/api/src/app/oauth/oauth-crypto.ts b/packages/server/api/src/app/oauth/oauth-crypto.ts new file mode 100644 index 0000000000..1e9216b467 --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth-crypto.ts @@ -0,0 +1,21 @@ +import crypto from 'node:crypto'; + +const TOKEN_BYTES = 32; + +export function generateOpaqueToken(): string { + return crypto.randomBytes(TOKEN_BYTES).toString('base64url'); +} + +export function sha256Hex(value: string): string { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +/** + * Hashes both sides before comparing so differing lengths cannot leak timing + * information (`crypto.timingSafeEqual` throws on length mismatch). + */ +export function timingSafeStringEqual(a: string, b: string): boolean { + const hashedA = crypto.createHash('sha256').update(a).digest(); + const hashedB = crypto.createHash('sha256').update(b).digest(); + return crypto.timingSafeEqual(hashedA, hashedB); +} diff --git a/packages/server/api/src/app/oauth/oauth-errors.ts b/packages/server/api/src/app/oauth/oauth-errors.ts new file mode 100644 index 0000000000..ab6962db84 --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth-errors.ts @@ -0,0 +1,46 @@ +/** + * RFC 6749 §5.2 error responses. The OpenOps `ApplicationError` envelope is not + * wire-compatible with OAuth clients, which branch on the `error` code to decide + * whether to retry, re-authorize, or discard a stored credential. + */ +export class OAuthError extends Error { + constructor( + public readonly errorCode: string, + public readonly description: string, + public readonly statusCode = 400, + ) { + super(`${errorCode}: ${description}`); + this.name = 'OAuthError'; + } + + toBody(): { error: string; error_description: string } { + return { error: this.errorCode, error_description: this.description }; + } +} + +export const invalidRequest = (description: string): OAuthError => + new OAuthError('invalid_request', description); + +export const invalidClient = (description: string): OAuthError => + new OAuthError('invalid_client', description, 401); + +export const invalidGrant = (description: string): OAuthError => + new OAuthError('invalid_grant', description); + +export const invalidTarget = (description: string): OAuthError => + new OAuthError('invalid_target', description); + +export const unsupportedGrantType = (description: string): OAuthError => + new OAuthError('unsupported_grant_type', description); + +export const unauthorizedClient = (description: string): OAuthError => + new OAuthError('unauthorized_client', description); + +export const invalidClientMetadata = (description: string): OAuthError => + new OAuthError('invalid_client_metadata', description); + +export const invalidRedirectUri = (description: string): OAuthError => + new OAuthError('invalid_redirect_uri', description); + +export const serverError = (description: string): OAuthError => + new OAuthError('server_error', description, 500); diff --git a/packages/server/api/src/app/oauth/oauth-metadata.ts b/packages/server/api/src/app/oauth/oauth-metadata.ts new file mode 100644 index 0000000000..3f6a2e7b99 --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth-metadata.ts @@ -0,0 +1,56 @@ +import { stripTrailingSlashes } from './canonical-url'; +import { oauthConfig } from './oauth-config'; +import { getSupportedScopes } from './resource-registry'; + +export type AuthorizationServerMetadata = { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + registration_endpoint: string; + revocation_endpoint: string; + jwks_uri: string; + response_types_supported: string[]; + grant_types_supported: string[]; + code_challenge_methods_supported: string[]; + token_endpoint_auth_methods_supported: string[]; + scopes_supported: string[]; + authorization_response_iss_parameter_supported: boolean; +}; + +/** + * RFC 8414 authorization server metadata. + * + * Only capabilities that are actually implemented are advertised. In particular + * there are no OpenID Connect claims here: no id tokens are issued, and stating + * otherwise would mislead clients that branch on those fields. + */ +export function buildAuthorizationServerMetadata(): AuthorizationServerMetadata { + const issuer = oauthConfig.getIssuerUrl(); + + return { + issuer, + authorization_endpoint: `${issuer}/v1/oauth/authorize`, + token_endpoint: `${issuer}/v1/oauth/token`, + registration_endpoint: `${issuer}/v1/oauth/register`, + revocation_endpoint: `${issuer}/v1/oauth/revoke`, + jwks_uri: `${issuer}/v1/oauth/jwks.json`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none', 'client_secret_basic'], + scopes_supported: getSupportedScopes(), + authorization_response_iss_parameter_supported: true, + }; +} + +/** + * RFC 8414 §3 places the metadata document under a path that keeps the issuer's + * own path component, so an issuer served under a sub-path is discoverable. + */ +export function getWellKnownPathVariants(basePath: string): string[] { + const issuerPath = stripTrailingSlashes( + new URL(oauthConfig.getIssuerUrl()).pathname, + ); + + return issuerPath ? [basePath, `${basePath}${issuerPath}`] : [basePath]; +} diff --git a/packages/server/api/src/app/oauth/oauth-model.ts b/packages/server/api/src/app/oauth/oauth-model.ts new file mode 100644 index 0000000000..71876b8398 --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth-model.ts @@ -0,0 +1,123 @@ +import { BaseModel } from '@openops/shared'; + +export type OAuthSigningKeyStatus = 'active' | 'retiring' | 'retired'; + +export type OAuthSigningKey = BaseModel & { + /** AES-encrypted PKCS#8 private key, serialized `EncryptedObject` JSON. */ + privateKeyEncrypted: string; + publicKeyPem: string; + status: OAuthSigningKeyStatus; +}; + +export type OAuthTokenEndpointAuthMethod = 'none' | 'client_secret_basic'; + +/** + * No `scope`. A client may send one at registration, but what a token actually gets is + * decided by the resource it names (see `resource-registry`), checked at `/authorize`. + * Storing the requested scope would be a second, unconsulted answer to the same question. + */ +export type OAuthClient = BaseModel & { + clientName: string; + redirectUris: string[]; + grantTypes: string[]; + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod; + clientSecretHash: string | null; +}; + +/** + * A validated `/authorize` request awaiting the user's decision. Holding the + * validated parameters server-side is what keeps consent from being forgeable + * through crafted URL parameters. The acting user is not known until the + * decision is submitted, so it is recorded on the grant instead. + */ +export type OAuthPendingAuthorization = BaseModel & { + clientId: string; + redirectUri: string; + codeChallenge: string; + resource: string; + scope: string; + state: string | null; + expiresAt: string; + consumedAt: string | null; +}; + +export type OAuthAuthorizationCode = BaseModel & { + codeHash: string; + clientId: string; + userId: string; + redirectUri: string; + codeChallenge: string; + resource: string; + scope: string; + expiresAt: string; + consumedAt: string | null; +}; + +/** No `userId`: the grant is where the acting user is recorded, and it is authoritative. */ +export type OAuthRefreshToken = BaseModel & { + tokenHash: string; + grantId: string; + /** Shared by every token rotated from the same original issuance. */ + familyId: string; + clientId: string; + resource: string; + scope: string; + /** + * Where this chain is currently acting. Carried forward on every rotation unless the + * client asks to move, so renewing a credential hands back an equivalent one instead + * of quietly returning the connection to wherever it started. + */ + projectId: string; + expiresAt: string; + revokedAt: string | null; +}; + +export type OAuthGrantStatus = 'active' | 'revoked'; + +/** + * One authorized connection. A user may hold several for the same client — each + * from a separate authorization — and revoke them independently. + * + * No `projectId`. Which project a connection acts in changes over its life, so it lives + * on the refresh token that carries the chain forward, not here — a copy on the grant + * could only be the project the connection started in, and using it as the refresh + * default silently undid switches. + * + * No `scope`: it would restate `resourceId`, since each resource grants exactly one. + * `revokedAt` is write-only on purpose — `status` is what code checks, and this answers + * "when" for anyone looking afterwards. + */ +export type OAuthGrant = BaseModel & { + clientId: string; + userId: string; + resourceId: string; + status: OAuthGrantStatus; + lastUsedAt: string | null; + revokedAt: string | null; +}; + +export type OAuthAccessTokenClaims = { + iss: string; + sub: string; + aud: string; + exp: number; + iat: number; + jti: string; + client_id: string; + scope: string; + grant_id: string; + /** + * The project this token may act on. Required, and fixed at mint time: the + * token's authority never changes after issuance, and the holder cannot + * redirect it at another project. + */ + project_id: string; +}; + +export type OAuthTokenResponse = { + access_token: string; + token_type: 'Bearer'; + expires_in: number; + scope: string; + refresh_token?: string; +}; diff --git a/packages/server/api/src/app/oauth/oauth-query.ts b/packages/server/api/src/app/oauth/oauth-query.ts new file mode 100644 index 0000000000..410033f356 --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth-query.ts @@ -0,0 +1,17 @@ +import { FindOperator, LessThan } from 'typeorm'; + +/** + * A "column is earlier than this instant" predicate. + * + * The timestamp columns are typed as `string` on the models because that is what + * application code reads and writes, but a comparison must be bound as a `Date`: + * drivers serialise dates in their own textual format, and comparing that against + * an ISO string is a *textual* comparison. SQLite, for instance, stores + * `2026-07-28 13:43:09.011` — every such value sorts below any `…T…Z` string, so + * an ISO-string predicate matches every row including future ones. + * + * The cast is confined here so the call sites stay readable. + */ +export function earlierThan(instant: Date): FindOperator { + return LessThan(instant) as unknown as FindOperator; +} diff --git a/packages/server/api/src/app/oauth/oauth-well-known.controller.ts b/packages/server/api/src/app/oauth/oauth-well-known.controller.ts new file mode 100644 index 0000000000..8321aa6256 --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth-well-known.controller.ts @@ -0,0 +1,56 @@ +import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'; +import { PUBLIC_ROUTE_POLICY } from '@openops/shared'; +import { + buildAuthorizationServerMetadata, + getWellKnownPathVariants, +} from './oauth-metadata'; +import { signingKeyService } from './signing-key.service'; + +const METADATA_CACHE_HEADER = 'public, max-age=300'; + +/** + * Discovery documents. The MCP authorization spec has clients look for the + * authorization server under both the RFC 8414 path and the OpenID Connect + * discovery path, so the same (truthful) document is served at both. + */ +export const oauthWellKnownController: FastifyPluginAsyncTypebox = async ( + app, +) => { + const metadataPaths = [ + ...getWellKnownPathVariants('/.well-known/oauth-authorization-server'), + ...getWellKnownPathVariants('/.well-known/openid-configuration'), + ]; + + for (const path of metadataPaths) { + app.get( + path, + { + config: { security: PUBLIC_ROUTE_POLICY }, + schema: { + description: 'OAuth 2.0 authorization server metadata (RFC 8414).', + }, + }, + async (_request, reply) => { + return reply + .header('Cache-Control', METADATA_CACHE_HEADER) + .send(buildAuthorizationServerMetadata()); + }, + ); + } + + app.get( + '/v1/oauth/jwks.json', + { + config: { security: PUBLIC_ROUTE_POLICY }, + schema: { + description: + 'Public keys for verifying OAuth-issued access tokens (RFC 7517).', + }, + }, + async (_request, reply) => { + const jwks = await signingKeyService.getJwks(); + + return reply.header('Cache-Control', METADATA_CACHE_HEADER).send(jwks); + }, + ); +}; diff --git a/packages/server/api/src/app/oauth/oauth.controller.ts b/packages/server/api/src/app/oauth/oauth.controller.ts new file mode 100644 index 0000000000..d96abb2956 --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth.controller.ts @@ -0,0 +1,437 @@ +import { RateLimitOptions } from '@fastify/rate-limit'; +import { + FastifyPluginAsyncTypebox, + Type, +} from '@fastify/type-provider-typebox'; +import { logger, SharedSystemProp, system } from '@openops/server-shared'; +import { PrincipalType, PUBLIC_ROUTE_POLICY } from '@openops/shared'; +import { FastifyReply } from 'fastify'; +import { StatusCodes } from 'http-status-codes'; +import { getUnscopedRoutePolicy } from '../core/security/route-policies/route-security-policy-factory'; +import { + AuthorizeQuery, + OAuthRequestBody, + optionalParam, + readParam, + requireParam, + validateAuthorizeRequest, +} from './authorize-validation'; +import { listAvailableProjects } from './available-projects'; +import { stripTrailingSlashes } from './canonical-url'; +import { clientsService, TOKEN_EXCHANGE_GRANT } from './clients.service'; +import { grantsService } from './grants.service'; +import { oauthConfig } from './oauth-config'; +import { invalidRequest, unsupportedGrantType } from './oauth-errors'; +import { OAuthClient } from './oauth-model'; +import { pendingAuthorizationService } from './pending-authorization.service'; +import { resolveResource } from './resource-registry'; +import { exchangeToken } from './token-exchange'; +import { tokensService } from './tokens.service'; + +const REGISTRATION_RATE_LIMIT: RateLimitOptions = { + max: 10, + timeWindow: '1 minute', +}; + +// Refresh is a routine background operation for connected agents, so this ceiling +// is well above normal use while still bounding brute-force attempts. +const TOKEN_RATE_LIMIT: RateLimitOptions = { + max: 120, + timeWindow: '1 minute', +}; + +/** + * Required on the consent decision. A cross-site form post cannot set a custom + * header, which — together with the single-use pending record — keeps a third + * party from driving the decision on a logged-in user's behalf. + */ +const CONSENT_HEADER = 'x-openops-consent'; + +function buildRedirectUrl( + redirectUri: string, + params: Record, +): string { + const url = new URL(redirectUri); + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, value); + } + } + + // RFC 9207: naming the issuer lets clients detect a mix-up between servers. + url.searchParams.set('iss', oauthConfig.getIssuerUrl()); + + return url.toString(); +} + +function renderAuthorizeError( + reply: FastifyReply, + error: string, + description: string, +): FastifyReply { + return reply + .status(StatusCodes.BAD_REQUEST) + .type('text/html') + .send( + `Authorization error` + + `

Authorization error

${escapeHtml(description)}

` + + `

${escapeHtml(error)}

`, + ); +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +function noStore(reply: FastifyReply): FastifyReply { + return reply.header('Cache-Control', 'no-store').header('Pragma', 'no-cache'); +} + +/** + * The consent screen is a dialog over the page that lists connected applications, so + * the user decides in the same place they later review and revoke what they granted. + */ +function getConsentUrl(requestId: string): string { + const frontendUrl = stripTrailingSlashes( + system.getOrThrow(SharedSystemProp.FRONTEND_URL), + ); + + return `${frontendUrl}/settings/connected-apps?request_id=${encodeURIComponent( + requestId, + )}`; +} + +export const oauthController: FastifyPluginAsyncTypebox = async (app) => { + app.post( + '/register', + { + config: { + security: PUBLIC_ROUTE_POLICY, + rateLimit: REGISTRATION_RATE_LIMIT, + }, + schema: { + description: + 'Register an OAuth client dynamically (RFC 7591). Registered clients are public clients and must use PKCE.', + }, + }, + async (request, reply) => { + const registered = await clientsService.registerClient(request.body); + + return noStore(reply).status(StatusCodes.CREATED).send(registered); + }, + ); + + app.get( + '/authorize', + { + config: { + security: PUBLIC_ROUTE_POLICY, + rateLimit: TOKEN_RATE_LIMIT, + }, + schema: { + description: + 'Start an authorization code flow. Validates the request and hands the browser an opaque request id for the consent screen.', + }, + }, + async (request, reply) => { + const query = request.query as AuthorizeQuery; + const clientId = readParam(query, 'client_id'); + const client = clientId ? await clientsService.getClient(clientId) : null; + + const validation = validateAuthorizeRequest(query, client); + + if (validation.kind === 'render_error') { + return renderAuthorizeError( + reply, + validation.error, + validation.description, + ); + } + + if (validation.kind === 'redirect_error') { + // Reached only once the client and its redirect_uri are known good, so + // this cannot be pointed at an unregistered destination. `state` is echoed + // verbatim; anything oversized was already refused above. + return reply.redirect( + buildRedirectUrl(validation.redirectUri, { + error: validation.error, + error_description: validation.description, + state: validation.state ?? undefined, + }), + ); + } + + const requestId = await pendingAuthorizationService.create({ + clientId: (client as OAuthClient).id, + redirectUri: validation.redirectUri, + codeChallenge: validation.codeChallenge, + resource: validation.resource.canonicalUri, + scope: validation.scope, + state: validation.state, + }); + + return reply.redirect(getConsentUrl(requestId)); + }, + ); + + app.get( + '/requests/:requestId', + { + config: { + security: getUnscopedRoutePolicy([PrincipalType.USER]), + }, + schema: { + description: + 'Details of a pending authorization request, for rendering the consent screen.', + params: Type.Object({ requestId: Type.String() }), + }, + }, + async (request) => { + const { requestId } = request.params as { requestId: string }; + const pending = await pendingAuthorizationService.get(requestId); + // Read from storage, never from the request: the displayed name is what the + // user bases their decision on, so it must not be attacker-supplied. + const client = await clientsService.getClientOrThrow(pending.clientId); + const resource = resolveResource(pending.resource); + + // No project is reported. A connection is not confined to one, so naming the + // project it happens to start in would read as a limit that does not exist. + return { + requestId, + clientName: client.clientName, + scope: pending.scope, + resourceId: resource?.id ?? null, + }; + }, + ); + + app.post( + '/requests/:requestId/decision', + { + config: { + security: getUnscopedRoutePolicy([PrincipalType.USER]), + }, + schema: { + description: + 'Approve or deny a pending authorization request and return the URL to send the browser to.', + params: Type.Object({ requestId: Type.String() }), + body: Type.Object({ approve: Type.Boolean() }), + }, + }, + async (request, reply) => { + if (request.headers[CONSENT_HEADER] === undefined) { + throw invalidRequest(`the ${CONSENT_HEADER} header is required`); + } + + const { requestId } = request.params as { requestId: string }; + const { approve } = request.body as { approve: boolean }; + + // Single-use: claiming the record here is what prevents a decision from + // being replayed into a second authorization code. + const pending = await pendingAuthorizationService.consume(requestId); + + if (!approve) { + return noStore(reply).send({ + redirectTo: buildRedirectUrl(pending.redirectUri, { + error: 'access_denied', + error_description: 'The user denied the request.', + state: pending.state ?? undefined, + }), + }); + } + + const code = await tokensService.issueAuthorizationCode( + pending, + request.principal.id, + ); + + logger.info('OAuth authorization approved', { + clientId: pending.clientId, + userId: request.principal.id, + resource: pending.resource, + }); + + return noStore(reply).send({ + redirectTo: buildRedirectUrl(pending.redirectUri, { + code, + state: pending.state ?? undefined, + }), + }); + }, + ); + + app.post( + '/token', + { + config: { + security: PUBLIC_ROUTE_POLICY, + rateLimit: TOKEN_RATE_LIMIT, + }, + schema: { + description: + 'Exchange an authorization code, refresh token, or subject token for an access token.', + }, + }, + async (request, reply) => { + const body = (request.body ?? {}) as OAuthRequestBody; + + switch (optionalParam(body, 'grant_type')) { + case 'authorization_code': + return noStore(reply).send(await handleAuthorizationCodeGrant(body)); + case 'refresh_token': + return noStore(reply).send(await handleRefreshTokenGrant(body)); + case TOKEN_EXCHANGE_GRANT: + return noStore(reply).send( + await exchangeToken({ + authorizationHeader: request.headers.authorization, + subjectToken: requireParam(body, 'subject_token'), + subjectTokenType: optionalParam(body, 'subject_token_type'), + requestedProjectId: optionalParam(body, 'project_id'), + }), + ); + default: + throw unsupportedGrantType( + `unsupported grant_type: ${ + optionalParam(body, 'grant_type') ?? 'missing' + }`, + ); + } + }, + ); + + app.post( + '/revoke', + { + config: { + security: PUBLIC_ROUTE_POLICY, + rateLimit: TOKEN_RATE_LIMIT, + }, + schema: { + description: + 'Revoke a refresh token and the connection it belongs to (RFC 7009).', + }, + }, + async (request, reply) => { + const body = (request.body ?? {}) as OAuthRequestBody; + const token = optionalParam(body, 'token'); + + if (token) { + await tokensService.revokeByRefreshToken(token); + } + + // RFC 7009 §2.2: an unknown token is not an error. + return noStore(reply).status(StatusCodes.OK).send({}); + }, + ); + + app.get( + '/projects', + { + config: { + // SERVICE as well as USER: this is the one route a connection itself calls, to + // find out where it may switch to. Nothing here is project data — only the + // names of projects the caller already has access to. + security: getUnscopedRoutePolicy([ + PrincipalType.USER, + PrincipalType.SERVICE, + ]), + }, + schema: { + description: + 'The projects the caller may act in, and which one they are acting in now.', + }, + }, + async (request) => { + const projects = await listAvailableProjects(request.principal.id); + + return { + data: projects, + currentProjectId: request.principal.projectId, + }; + }, + ); + + app.get( + '/grants', + { + config: { + security: getUnscopedRoutePolicy([PrincipalType.USER]), + }, + schema: { + description: 'List the connected applications for the current user.', + }, + }, + async (request) => { + const grants = await grantsService.listForUser(request.principal.id); + const clients = await Promise.all( + grants.map((grant) => clientsService.getClient(grant.clientId)), + ); + + return { + data: grants.map((grant, index) => ({ + id: grant.id, + clientName: clients[index]?.clientName ?? 'Unknown application', + resourceId: grant.resourceId, + created: grant.created, + lastUsedAt: grant.lastUsedAt, + })), + }; + }, + ); + + app.delete( + '/grants/:grantId', + { + config: { + security: getUnscopedRoutePolicy([PrincipalType.USER]), + }, + schema: { + description: + 'Revoke a connected application, invalidating its refresh tokens.', + params: Type.Object({ grantId: Type.String() }), + }, + }, + async (request, reply) => { + const { grantId } = request.params as { grantId: string }; + + await grantsService.revokeForUser(grantId, request.principal.id); + + return reply.status(StatusCodes.OK).send({}); + }, + ); +}; + +async function handleAuthorizationCodeGrant( + body: OAuthRequestBody, +): Promise { + const clientId = requireParam(body, 'client_id'); + const client = await clientsService.getClientOrThrow(clientId); + clientsService.assertGrantTypeAllowed(client, 'authorization_code'); + + return tokensService.redeemAuthorizationCode({ + code: requireParam(body, 'code'), + clientId, + redirectUri: requireParam(body, 'redirect_uri'), + codeVerifier: requireParam(body, 'code_verifier'), + resource: requireParam(body, 'resource'), + }); +} + +async function handleRefreshTokenGrant( + body: OAuthRequestBody, +): Promise { + const clientId = requireParam(body, 'client_id'); + const client = await clientsService.getClientOrThrow(clientId); + clientsService.assertGrantTypeAllowed(client, 'refresh_token'); + + return tokensService.rotateRefreshToken({ + refreshToken: requireParam(body, 'refresh_token'), + clientId, + requestedProjectId: optionalParam(body, 'project_id'), + }); +} diff --git a/packages/server/api/src/app/oauth/oauth.entity.ts b/packages/server/api/src/app/oauth/oauth.entity.ts new file mode 100644 index 0000000000..84cabec3bc --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth.entity.ts @@ -0,0 +1,154 @@ +import { EntitySchema } from 'typeorm'; +import { + BaseColumnSchemaPart, + JSONB_COLUMN_TYPE, + OpenOpsIdSchema, + TIMESTAMP_COLUMN_TYPE, +} from '../database/database-common'; +import { + OAuthAuthorizationCode, + OAuthClient, + OAuthGrant, + OAuthPendingAuthorization, + OAuthRefreshToken, + OAuthSigningKey, +} from './oauth-model'; + +const SHA256_HEX_LENGTH = 64; +const URI_LENGTH = 512; +const CODE_CHALLENGE_LENGTH = 43; + +export const OAuthSigningKeyEntity = new EntitySchema({ + name: 'oauth_signing_key', + columns: { + ...BaseColumnSchemaPart, + privateKeyEncrypted: { type: String }, + publicKeyPem: { type: String }, + status: { type: String, length: 16 }, + }, + // Partial unique index, mirroring the migration: it is what makes concurrently + // booting replicas converge on one active key instead of each inserting one. + indices: [ + { + name: 'idx_oauth_signing_key_single_active', + columns: ['status'], + unique: true, + where: '"status" = \'active\'', + }, + ], +}); + +export const OAuthClientEntity = new EntitySchema({ + name: 'oauth_client', + columns: { + ...BaseColumnSchemaPart, + clientName: { type: String, length: 128 }, + redirectUris: { type: JSONB_COLUMN_TYPE }, + grantTypes: { type: JSONB_COLUMN_TYPE }, + tokenEndpointAuthMethod: { type: String, length: 32 }, + clientSecretHash: { + type: String, + length: SHA256_HEX_LENGTH, + nullable: true, + }, + }, + indices: [], +}); + +export const OAuthPendingAuthorizationEntity = + new EntitySchema({ + name: 'oauth_pending_authorization', + columns: { + ...BaseColumnSchemaPart, + clientId: { ...OpenOpsIdSchema }, + redirectUri: { type: String, length: URI_LENGTH }, + codeChallenge: { type: String, length: CODE_CHALLENGE_LENGTH }, + resource: { type: String, length: URI_LENGTH }, + scope: { type: String, length: 128 }, + state: { type: String, nullable: true }, + expiresAt: { type: TIMESTAMP_COLUMN_TYPE }, + consumedAt: { type: TIMESTAMP_COLUMN_TYPE, nullable: true }, + }, + indices: [ + { + name: 'idx_oauth_pending_authorization_expires_at', + columns: ['expiresAt'], + }, + ], + }); + +export const OAuthAuthorizationCodeEntity = + new EntitySchema({ + name: 'oauth_authorization_code', + columns: { + ...BaseColumnSchemaPart, + codeHash: { type: String, length: SHA256_HEX_LENGTH }, + clientId: { ...OpenOpsIdSchema }, + userId: { ...OpenOpsIdSchema }, + redirectUri: { type: String, length: URI_LENGTH }, + codeChallenge: { type: String, length: CODE_CHALLENGE_LENGTH }, + resource: { type: String, length: URI_LENGTH }, + scope: { type: String, length: 128 }, + expiresAt: { type: TIMESTAMP_COLUMN_TYPE }, + consumedAt: { type: TIMESTAMP_COLUMN_TYPE, nullable: true }, + }, + indices: [ + { + name: 'idx_oauth_authorization_code_code_hash', + columns: ['codeHash'], + unique: true, + }, + { + name: 'idx_oauth_authorization_code_expires_at', + columns: ['expiresAt'], + }, + ], + }); + +export const OAuthRefreshTokenEntity = new EntitySchema({ + name: 'oauth_refresh_token', + columns: { + ...BaseColumnSchemaPart, + tokenHash: { type: String, length: SHA256_HEX_LENGTH }, + grantId: { ...OpenOpsIdSchema }, + familyId: { ...OpenOpsIdSchema }, + clientId: { ...OpenOpsIdSchema }, + resource: { type: String, length: URI_LENGTH }, + scope: { type: String, length: 128 }, + projectId: { ...OpenOpsIdSchema }, + expiresAt: { type: TIMESTAMP_COLUMN_TYPE }, + revokedAt: { type: TIMESTAMP_COLUMN_TYPE, nullable: true }, + }, + indices: [ + { + name: 'idx_oauth_refresh_token_token_hash', + columns: ['tokenHash'], + unique: true, + }, + { name: 'idx_oauth_refresh_token_grant_id', columns: ['grantId'] }, + { name: 'idx_oauth_refresh_token_family_id', columns: ['familyId'] }, + { name: 'idx_oauth_refresh_token_expires_at', columns: ['expiresAt'] }, + ], +}); + +export const OAuthGrantEntity = new EntitySchema({ + name: 'oauth_grant', + columns: { + ...BaseColumnSchemaPart, + clientId: { ...OpenOpsIdSchema }, + userId: { ...OpenOpsIdSchema }, + resourceId: { type: String, length: 32 }, + status: { type: String, length: 16 }, + lastUsedAt: { type: TIMESTAMP_COLUMN_TYPE, nullable: true }, + revokedAt: { type: TIMESTAMP_COLUMN_TYPE, nullable: true }, + }, + // Deliberately not unique on (clientId, userId): a user may connect the same + // agent more than once, and each connection is revoked on its own. + indices: [ + { + name: 'idx_oauth_grant_client_id_user_id', + columns: ['clientId', 'userId'], + }, + { name: 'idx_oauth_grant_user_id', columns: ['userId'] }, + ], +}); diff --git a/packages/server/api/src/app/oauth/oauth.module.ts b/packages/server/api/src/app/oauth/oauth.module.ts new file mode 100644 index 0000000000..57370cca93 --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth.module.ts @@ -0,0 +1,46 @@ +import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'; +import { logger } from '@openops/server-shared'; +import { clientsService } from './clients.service'; +import { scheduleOAuthCleanupJob } from './oauth-cleanup-job'; +import { validateOAuthConfiguration } from './oauth-config-validation'; +import { OAuthError } from './oauth-errors'; +import { oauthWellKnownController } from './oauth-well-known.controller'; +import { oauthController } from './oauth.controller'; +import { signingKeyService } from './signing-key.service'; + +export const oauthModule: FastifyPluginAsyncTypebox = async (app) => { + validateOAuthConfiguration(); + + await signingKeyService.ensureSigningKey(); + await clientsService.ensureResourceServerClient(); + await scheduleOAuthCleanupJob(); + + await app.register( + async (instance) => { + // OAuth clients branch on the RFC 6749 `error` code to decide whether to + // retry, re-authorize, or discard a stored credential, so these routes + // must not use the application's own error envelope. + instance.setErrorHandler((error, _request, reply) => { + if (error instanceof OAuthError) { + logger.debug('OAuth request rejected', { + error: error.errorCode, + description: error.description, + }); + + return reply + .status(error.statusCode) + .header('Cache-Control', 'no-store') + .send(error.toBody()); + } + + throw error; + }); + + await instance.register(oauthController, { prefix: '/v1/oauth' }); + await instance.register(oauthWellKnownController); + }, + { prefix: '/' }, + ); + + logger.info('OAuth authorization server enabled'); +}; diff --git a/packages/server/api/src/app/oauth/pending-authorization.service.ts b/packages/server/api/src/app/oauth/pending-authorization.service.ts new file mode 100644 index 0000000000..bc4bf12e61 --- /dev/null +++ b/packages/server/api/src/app/oauth/pending-authorization.service.ts @@ -0,0 +1,123 @@ +import { openOpsId } from '@openops/shared'; +import { IsNull } from 'typeorm'; +import { repoFactory } from '../core/db/repo-factory'; +import { invalidRequest } from './oauth-errors'; +import { OAuthPendingAuthorization } from './oauth-model'; +import { earlierThan } from './oauth-query'; +import { OAuthPendingAuthorizationEntity } from './oauth.entity'; + +const repo = repoFactory( + OAuthPendingAuthorizationEntity, +); + +/** RFC 6749 §4.1.1 gives no bound; ten minutes is long enough to log in and + * read the consent screen, short enough to limit the window in which a leaked + * request id is useful. */ +export const PENDING_AUTHORIZATION_TTL_MS = 10 * 60 * 1000; + +/** + * Unknown, expired and already-consumed requests are all reported with this + * exact text: a distinguishable error would turn the consent endpoint into an + * oracle for which request ids exist. + */ +const UNUSABLE_REQUEST = 'unknown or expired authorization request'; + +export type CreatePendingAuthorizationParams = { + clientId: string; + redirectUri: string; + codeChallenge: string; + resource: string; + scope: string; + state: string | null; +}; + +function isExpired(record: OAuthPendingAuthorization, now: number): boolean { + return new Date(record.expiresAt).getTime() <= now; +} + +export const pendingAuthorizationService = { + /** + * Stores the parameters `/authorize` has already validated so nothing about + * the request can be re-supplied — and therefore tampered with — by the + * browser. The id is a 21-char nanoid (~125 bits of entropy), unguessable + * enough to be the sole handle the user agent carries, and it fits the + * varchar(21) id column. + */ + async create(params: CreatePendingAuthorizationParams): Promise { + const id = openOpsId(); + const now = new Date(); + + await repo().insert({ + id, + created: now.toISOString(), + updated: now.toISOString(), + clientId: params.clientId, + redirectUri: params.redirectUri, + codeChallenge: params.codeChallenge, + resource: params.resource, + scope: params.scope, + state: params.state, + expiresAt: new Date( + now.getTime() + PENDING_AUTHORIZATION_TTL_MS, + ).toISOString(), + consumedAt: null, + }); + + return id; + }, + + /** Read-only lookup for rendering the consent screen. */ + async get(id: string): Promise { + const record = await repo().findOneBy({ id }); + + if ( + !record || + record.consumedAt !== null || + isExpired(record, Date.now()) + ) { + throw invalidRequest(UNUSABLE_REQUEST); + } + + return record; + }, + + /** + * Claims the request for the decision that is being submitted. The + * conditional update is the single-use guarantee: two concurrent submissions + * race on `consumedAt IS NULL` in the database, so exactly one can ever + * proceed to mint an authorization code. + */ + async consume(id: string): Promise { + const consumedAt = new Date().toISOString(); + // Some drivers report `affected` as null/undefined; anything but a single + // claimed row means another request already took it. + const result = await repo().update( + { id, consumedAt: IsNull() }, + { consumedAt }, + ); + + if (result.affected !== 1) { + throw invalidRequest(UNUSABLE_REQUEST); + } + + const record = await repo().findOneBy({ id }); + + if (!record || isExpired(record, new Date(consumedAt).getTime())) { + throw invalidRequest(UNUSABLE_REQUEST); + } + + return record; + }, + + /** + * Cleanup job hook: expired requests can never be used again. Takes a `Date` so + * the driver serialises the comparison the same way it serialised the stored + * value; an ISO string is compared textually by drivers that store a different + * textual format, which matches every row. + */ + async deleteExpired(now = new Date()): Promise { + const result = await repo().delete({ expiresAt: earlierThan(now) }); + + return result.affected ?? 0; + }, +}; diff --git a/packages/server/api/src/app/oauth/pkce.ts b/packages/server/api/src/app/oauth/pkce.ts new file mode 100644 index 0000000000..2d250816cc --- /dev/null +++ b/packages/server/api/src/app/oauth/pkce.ts @@ -0,0 +1,27 @@ +import crypto from 'node:crypto'; +import { timingSafeStringEqual } from './oauth-crypto'; + +// RFC 7636 §4.1: 43-128 chars from the unreserved set. +const VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/; +// A base64url-encoded SHA-256 digest is always 43 chars. +const CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/; + +export function isValidCodeChallenge(codeChallenge: string): boolean { + return CHALLENGE_PATTERN.test(codeChallenge); +} + +export function verifyPkce( + codeVerifier: string, + codeChallenge: string, +): boolean { + if (!VERIFIER_PATTERN.test(codeVerifier)) { + return false; + } + + const computed = crypto + .createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + + return timingSafeStringEqual(computed, codeChallenge); +} diff --git a/packages/server/api/src/app/oauth/project-membership-factory.ts b/packages/server/api/src/app/oauth/project-membership-factory.ts new file mode 100644 index 0000000000..10f8a35346 --- /dev/null +++ b/packages/server/api/src/app/oauth/project-membership-factory.ts @@ -0,0 +1,8 @@ +import { + oauthProjectMembershipService, + OAuthProjectMembershipService, +} from './project-membership'; + +export function getOAuthProjectMembershipService(): OAuthProjectMembershipService { + return oauthProjectMembershipService; +} diff --git a/packages/server/api/src/app/oauth/project-membership.ts b/packages/server/api/src/app/oauth/project-membership.ts new file mode 100644 index 0000000000..dc1cf71b44 --- /dev/null +++ b/packages/server/api/src/app/oauth/project-membership.ts @@ -0,0 +1,99 @@ +import { isNil, User } from '@openops/shared'; +import { projectService } from '../project/project-service'; + +/** + * What an OAuth connection is allowed to act as, for one project. + * + * `projectRole` is a plain string rather than an enum because the role model is + * an enterprise concern: this edition has no per-project roles and reports the + * same value the session login path does. + */ +export type OAuthProjectMembership = { + projectId: string; + organizationId: string; + projectRole: string; +}; + +/** + * The three questions the OAuth server asks about projects. Kept behind a factory + * (`project-membership-factory.ts`) so an edition with real multi-project + * membership can answer them without the OAuth code changing. + */ +export type OAuthProjectMembershipService = { + /** Where a newly authorized connection starts. */ + getDefaultForUser(user: User): Promise; + /** + * Whether this user may act in this project, and as what. Called on every + * request that presents an OAuth token, so losing access takes effect without + * waiting for the token to expire. + */ + getForUser( + user: User, + projectId: string, + ): Promise; + /** + * Every project the connection may act in — what a client lists to decide where + * to switch to. Membership is the authority, so this is the same set the user + * could reach in the browser. + */ + listForUser(user: User): Promise; +}; + +// This edition has one project per organization and no role model, so both +// questions reduce to "is this the organization's project". +const PROJECT_ROLE = 'ADMIN'; + +export const oauthProjectMembershipService: OAuthProjectMembershipService = { + async getDefaultForUser(user: User): Promise { + const project = await projectService.getOneForUser(user); + + if (isNil(project)) { + return null; + } + + return { + projectId: project.id, + organizationId: project.organizationId, + projectRole: PROJECT_ROLE, + }; + }, + + async getForUser( + user: User, + projectId: string, + ): Promise { + const project = await projectService.getOne(projectId); + + if (isNil(project) || project.organizationId !== user.organizationId) { + return null; + } + + return { + projectId: project.id, + organizationId: project.organizationId, + projectRole: PROJECT_ROLE, + }; + }, + + async listForUser(user: User): Promise { + // Deliberately the same rule as `getForUser` — every project in the user's + // organization — rather than "the one project this edition expects". If this + // listed less than `getForUser` allows, a client could be told it may only act in + // one place while the token endpoint happily switched it to another it was never + // shown. In practice this edition has one project per organization and the list + // has a single entry. + if (isNil(user.organizationId)) { + return []; + } + + const projectIds = await projectService.getProjectIdsByOrganizationId( + user.organizationId, + ); + + return projectIds.map((projectId) => ({ + projectId, + organizationId: user.organizationId as string, + projectRole: PROJECT_ROLE, + })); + }, +}; diff --git a/packages/server/api/src/app/oauth/redirect-uri.ts b/packages/server/api/src/app/oauth/redirect-uri.ts new file mode 100644 index 0000000000..f6b551c03f --- /dev/null +++ b/packages/server/api/src/app/oauth/redirect-uri.ts @@ -0,0 +1,84 @@ +// `URL.hostname` keeps the brackets for IPv6 literals. +const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', '[::1]', 'localhost']); +const MAX_URI_LENGTH = 512; + +function parseUri(uri: string): URL | undefined { + try { + return new URL(uri); + } catch { + return undefined; + } +} + +function isLoopback(url: URL): boolean { + return url.protocol === 'http:' && LOOPBACK_HOSTNAMES.has(url.hostname); +} + +/** + * Shape rules a redirect URI must satisfy to be used at all, whether it arrives + * at registration or on an authorize request. + * + * Only https and, per RFC 8252 §7.3, http loopback for native clients. Fragments + * are forbidden by RFC 6749 §3.1.2. Userinfo is rejected because the server + * echoes this value back in a `Location` header, and credentials embedded in that + * URL would be attacker-supplied content the user is redirected through. The + * length cap keeps a presented value inside its storage column. + */ +function isUsableRedirectUri(uri: string): boolean { + if ( + typeof uri !== 'string' || + uri.length === 0 || + uri.length > MAX_URI_LENGTH + ) { + return false; + } + + const url = parseUri(uri); + if (!url || url.hash !== '' || url.username !== '' || url.password !== '') { + return false; + } + + return url.protocol === 'https:' || isLoopback(url); +} + +export function isRegistrableRedirectUri(uri: string): boolean { + return isUsableRedirectUri(uri); +} + +/** + * Exact string matching, except loopback redirects match on any port because + * native clients bind an ephemeral port at request time (RFC 8252 §7.3). + */ +export function matchesRegisteredRedirectUri( + registeredUris: string[], + presentedUri: string, +): boolean { + // Held to the same shape rules as a registered value. Loopback matching ignores + // the port, so without this a presented URI could carry a fragment, userinfo or + // an unbounded length past the checks that registration applied. + if (!isUsableRedirectUri(presentedUri)) { + return false; + } + + const presented = parseUri(presentedUri); + if (!presented) { + return false; + } + + return registeredUris.some((registeredUri) => { + if (registeredUri === presentedUri) { + return true; + } + + const registered = parseUri(registeredUri); + if (!registered || !isLoopback(registered) || !isLoopback(presented)) { + return false; + } + + return ( + registered.hostname === presented.hostname && + registered.pathname === presented.pathname && + registered.search === presented.search + ); + }); +} diff --git a/packages/server/api/src/app/oauth/resource-registry.ts b/packages/server/api/src/app/oauth/resource-registry.ts new file mode 100644 index 0000000000..68ade92904 --- /dev/null +++ b/packages/server/api/src/app/oauth/resource-registry.ts @@ -0,0 +1,59 @@ +import { stripTrailingSlashes } from './canonical-url'; +import { oauthConfig } from './oauth-config'; + +export type ResourceId = 'api' | 'mcp'; + +export type RegisteredResource = { + id: ResourceId; + audience: string; + canonicalUri: string; + scopes: string[]; +}; + +/** + * RFC 8707 resource indicators the authorization server will issue tokens for. + * + * A token for the `api` resource is used against the OpenOps API directly. A token + * for `mcp` is only ever accepted by the resource server, which exchanges it for + * an API-audience token — enforced by the audience check in `token-exchange.ts`, + * not by anything recorded here. + */ +export function getRegisteredResources(): RegisteredResource[] { + const apiAudience = oauthConfig.getApiAudience(); + + const resources: RegisteredResource[] = [ + { + id: 'api', + audience: apiAudience, + canonicalUri: apiAudience, + scopes: ['api'], + }, + ]; + + const mcpResourceUrl = oauthConfig.getMcpResourceUrl(); + if (mcpResourceUrl) { + resources.push({ + id: 'mcp', + audience: mcpResourceUrl, + canonicalUri: mcpResourceUrl, + scopes: ['mcp'], + }); + } + + return resources; +} + +export function resolveResource( + resource: string, +): RegisteredResource | undefined { + if (!resource) { + return undefined; + } + + const normalized = stripTrailingSlashes(resource); + return getRegisteredResources().find((r) => r.canonicalUri === normalized); +} + +export function getSupportedScopes(): string[] { + return getRegisteredResources().flatMap((r) => r.scopes); +} diff --git a/packages/server/api/src/app/oauth/service-principal.ts b/packages/server/api/src/app/oauth/service-principal.ts new file mode 100644 index 0000000000..3639ff4f7a --- /dev/null +++ b/packages/server/api/src/app/oauth/service-principal.ts @@ -0,0 +1,69 @@ +import { isNil, Principal, PrincipalType, UserStatus } from '@openops/shared'; +import { userService } from '../user/user-service'; +import { grantsService } from './grants.service'; +import { invalidGrant } from './oauth-errors'; +import { OAuthAccessTokenClaims } from './oauth-model'; +import { getOAuthProjectMembershipService } from './project-membership-factory'; + +/** + * Turns a verified OAuth access token into a request principal. + * + * The token's audience is checked before this is reached, so it is known to be + * addressed to the API. The project comes from the token's own `project_id` + * claim, which means a token can only ever act on the project it was minted for. + * + * What is re-checked on every request is everything that can change after the + * token was issued: the connection may have been revoked, the user deactivated, + * or their access to that project withdrawn. Access tokens are self-contained, + * so this is what makes those changes take effect without waiting for expiry. + */ +export async function buildOAuthServicePrincipal( + claims: OAuthAccessTokenClaims, +): Promise { + if (!claims.grant_id) { + throw invalidGrant('token is not bound to an authorization'); + } + + // Required: a token with no project names no authority, and falling back to + // stored state would reintroduce a second source of truth. + if (!claims.project_id) { + throw invalidGrant('token is not bound to a project'); + } + + const grant = await grantsService.getActiveGrantOrThrow(claims.grant_id); + + if (grant.userId !== claims.sub) { + throw invalidGrant('token does not match its authorization'); + } + + const user = await userService.get({ id: grant.userId }); + + if (isNil(user) || user.status !== UserStatus.ACTIVE) { + throw invalidGrant('the user for this authorization is no longer active'); + } + + const membership = await getOAuthProjectMembershipService().getForUser( + user, + claims.project_id, + ); + + if (isNil(membership)) { + throw invalidGrant('the project for this authorization is not accessible'); + } + + // Recorded here as well as at token exchange, so a connection used directly + // against the API still shows a last-used time. Throttled internally. + await grantsService.touch(grant.id); + + return { + id: user.id, + externalId: user.externalId, + type: PrincipalType.SERVICE, + projectId: membership.projectId, + projectRole: membership.projectRole, + organization: { + id: membership.organizationId, + role: user.organizationRole, + }, + }; +} diff --git a/packages/server/api/src/app/oauth/signing-key.service.ts b/packages/server/api/src/app/oauth/signing-key.service.ts new file mode 100644 index 0000000000..94482b314b --- /dev/null +++ b/packages/server/api/src/app/oauth/signing-key.service.ts @@ -0,0 +1,237 @@ +import { encryptUtils, logger } from '@openops/server-shared'; +import { EncryptedObject, openOpsId } from '@openops/shared'; +import jwt from 'jsonwebtoken'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { repoFactory } from '../core/db/repo-factory'; +import { oauthConfig } from './oauth-config'; +import { invalidGrant, serverError } from './oauth-errors'; +import { OAuthSigningKey } from './oauth-model'; +import { OAuthSigningKeyEntity } from './oauth.entity'; + +const repo = repoFactory(OAuthSigningKeyEntity); + +const ALGORITHM = 'RS256'; +const MODULUS_LENGTH = 2048; +const UNIQUE_VIOLATION = '23505'; +const KEY_CACHE_TTL_MS = 5 * 60 * 1000; +const OPERATOR_KEY_ID_LENGTH = 16; + +type LoadedKeys = { + signing: { kid: string; privateKeyPem: string }; + /** Every key a token may legitimately have been signed with: active + retiring. */ + verification: Map; + loadedAt: number; +}; + +let cachedKeys: LoadedKeys | undefined; + +function toPublicKeyPem(privateKeyPem: string): string { + return crypto + .createPublicKey(privateKeyPem) + .export({ type: 'spki', format: 'pem' }) as string; +} + +function loadOperatorProvidedKey(pemPath: string): LoadedKeys { + const privateKeyPem = fs.readFileSync(pemPath, 'utf-8'); + const publicKeyPem = toPublicKeyPem(privateKeyPem); + const kid = crypto + .createHash('sha256') + .update(publicKeyPem) + .digest('hex') + .slice(0, OPERATOR_KEY_ID_LENGTH); + + return { + signing: { kid, privateKeyPem }, + verification: new Map([[kid, publicKeyPem]]), + loadedAt: Date.now(), + }; +} + +async function loadKeysFromDatabase(): Promise { + const keys = await repo().find(); + const activeKey = keys.find((key) => key.status === 'active'); + + if (!activeKey) { + throw serverError('OAuth signing key is not initialized'); + } + + const verification = new Map( + keys + .filter((key) => key.status !== 'retired') + .map((key) => [key.id, key.publicKeyPem]), + ); + + const privateKeyPem = encryptUtils.decryptString( + JSON.parse(activeKey.privateKeyEncrypted) as EncryptedObject, + ); + + return { + signing: { kid: activeKey.id, privateKeyPem }, + verification, + loadedAt: Date.now(), + }; +} + +async function loadKeys(): Promise { + if (cachedKeys && Date.now() - cachedKeys.loadedAt < KEY_CACHE_TTL_MS) { + return cachedKeys; + } + + const pemPath = oauthConfig.getSigningKeyPemPath(); + + try { + cachedKeys = pemPath + ? loadOperatorProvidedKey(pemPath) + : await loadKeysFromDatabase(); + } catch (error) { + // Keys change only on rotation, so a stale copy is still correct. Serving it + // through a database outage keeps every already-issued token verifiable, + // where failing would tell every connected agent its credential is invalid. + if (!cachedKeys) { + throw error; + } + + logger.warn('Reusing cached OAuth signing keys after a failed reload', { + error, + }); + cachedKeys.loadedAt = Date.now(); + } + + return cachedKeys; +} + +export const signingKeyService = { + /** + * Generates the OAuth signing keypair on first boot so a self-hosted install + * needs no key configuration. Concurrent replicas race on the partial unique + * index over `status = 'active'`; the loser simply reuses the winner's key. + */ + async ensureSigningKey(): Promise { + if (oauthConfig.getSigningKeyPemPath()) { + return; + } + + const existingKey = await repo().findOneBy({ status: 'active' }); + if (existingKey) { + return; + } + + const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: MODULUS_LENGTH, + }); + const privateKeyPem = privateKey.export({ + type: 'pkcs8', + format: 'pem', + }) as string; + const publicKeyPem = publicKey.export({ + type: 'spki', + format: 'pem', + }) as string; + const now = new Date().toISOString(); + + try { + await repo().insert({ + id: openOpsId(), + created: now, + updated: now, + privateKeyEncrypted: JSON.stringify( + encryptUtils.encryptString(privateKeyPem), + ), + publicKeyPem, + status: 'active', + }); + logger.info('OAuth signing key generated'); + } catch (error) { + if ((error as { code?: string }).code !== UNIQUE_VIOLATION) { + throw error; + } + logger.info('OAuth signing key already created by another instance'); + } + }, + + async getJwks(): Promise<{ keys: Record[] }> { + const keys = await loadKeys(); + + return { + keys: [...keys.verification.entries()].map(([kid, publicKeyPem]) => ({ + ...(crypto + .createPublicKey(publicKeyPem) + .export({ format: 'jwk' }) as Record), + kid, + alg: ALGORITHM, + use: 'sig', + })), + }; + }, + + /** + * `project_id` is part of the signed payload rather than looked up per request, + * so a token's authority is fixed for its whole life: it can only ever act on + * the project it was minted for. + */ + async signAccessToken( + claims: { + sub: string; + aud: string; + client_id: string; + scope: string; + grant_id: string; + project_id: string; + }, + ttlSeconds: number, + ): Promise { + const keys = await loadKeys(); + + return jwt.sign( + { ...claims, jti: openOpsId() }, + keys.signing.privateKeyPem, + { + algorithm: ALGORITHM, + keyid: keys.signing.kid, + issuer: oauthConfig.getIssuerUrl(), + expiresIn: ttlSeconds, + }, + ); + }, + + /** + * Verifies an OAuth-issued token, requiring the exact audience the caller + * expects. Audience is checked here rather than by callers so no code path can + * accept a token minted for a different resource. + */ + async verifyAccessToken( + token: string, + expectedAudience: string, + ): Promise> { + const decoded = jwt.decode(token, { complete: true }); + const kid = decoded?.header?.kid; + + if (!kid) { + throw invalidGrant('token has no key id'); + } + + const keys = await loadKeys(); + const publicKeyPem = keys.verification.get(kid); + + if (!publicKeyPem) { + throw invalidGrant('token signed by an unknown key'); + } + + try { + return jwt.verify(token, publicKeyPem, { + algorithms: [ALGORITHM], + issuer: oauthConfig.getIssuerUrl(), + audience: expectedAudience, + }) as Record; + } catch (error) { + throw invalidGrant( + `token verification failed: ${(error as Error).message}`, + ); + } + }, + + clearKeyCacheForTests(): void { + cachedKeys = undefined; + }, +}; diff --git a/packages/server/api/src/app/oauth/token-exchange.ts b/packages/server/api/src/app/oauth/token-exchange.ts new file mode 100644 index 0000000000..e3aafdadc2 --- /dev/null +++ b/packages/server/api/src/app/oauth/token-exchange.ts @@ -0,0 +1,131 @@ +import { isNil, UserStatus } from '@openops/shared'; +import { userService } from '../user/user-service'; +import { clientsService, TOKEN_EXCHANGE_GRANT } from './clients.service'; +import { grantsService } from './grants.service'; +import { oauthConfig } from './oauth-config'; +import { invalidGrant, invalidRequest, invalidTarget } from './oauth-errors'; +import { getOAuthProjectMembershipService } from './project-membership-factory'; +import { signingKeyService } from './signing-key.service'; +import { tokensService } from './tokens.service'; + +const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'; +const EXCHANGED_SCOPE = 'api'; + +export type ExchangeTokenParams = { + authorizationHeader: string | undefined; + subjectToken: string; + subjectTokenType?: string; + /** Act in this project instead of the subject token's. Must be one the user has. */ + requestedProjectId?: string; +}; + +export type ExchangeTokenResponse = { + access_token: string; + issued_token_type: string; + token_type: 'Bearer'; + expires_in: number; + scope: string; +}; + +/** + * RFC 8693 token exchange for the hosted MCP resource server. + * + * The client's token is audience-bound to the MCP resource and must never reach + * the OpenOps API (the MCP authorization spec's no-token-passthrough rule), so + * the resource server presents it here and receives a separate, short-lived + * API-audience token. Two distinct credentials, never one forwarded. + */ +export async function exchangeToken( + params: ExchangeTokenParams, +): Promise { + // Authenticate before doing any work, so an unauthenticated caller cannot use + // this endpoint to probe token or grant state. + const client = await clientsService.authenticateResourceServerClient( + params.authorizationHeader, + ); + clientsService.assertGrantTypeAllowed(client, TOKEN_EXCHANGE_GRANT); + + if ( + !isNil(params.subjectTokenType) && + params.subjectTokenType !== ACCESS_TOKEN_TYPE + ) { + throw invalidRequest(`subject_token_type must be ${ACCESS_TOKEN_TYPE}`); + } + + const mcpResourceUrl = oauthConfig.getMcpResourceUrl(); + + if (isNil(mcpResourceUrl)) { + throw invalidTarget('the mcp resource is not configured'); + } + + // Pinning the expected audience to the MCP resource is what makes the + // separation real: an API-audience token presented here fails verification. + const claims = await signingKeyService.verifyAccessToken( + params.subjectToken, + mcpResourceUrl, + ); + + const grantId = claims['grant_id']; + + if (typeof grantId !== 'string') { + throw invalidGrant('token is not bound to an authorization'); + } + + // Access tokens are self-contained, so this is the revocation check for every + // MCP request that reaches the API. + const grant = await grantsService.getActiveGrantOrThrow(grantId); + + // Re-checked here as well as on issuance so deactivating a user takes effect + // promptly rather than when their tokens happen to expire. + const user = await userService.get({ id: grant.userId }); + + if (isNil(user) || user.status !== UserStatus.ACTIVE) { + throw invalidGrant('the user for this authorization is no longer active'); + } + + const subjectProjectId = claims['project_id']; + + if (typeof subjectProjectId !== 'string') { + throw invalidGrant('token is not bound to a project'); + } + + /* + * Which project the exchanged token acts in. + * + * By default the subject token's, so the pair refer to the same place. A resource + * server may name a different one, which is how an agent switches project without + * the user re-authorizing: the MCP server has no way to mint tokens itself, so it + * asks here and this decides. + * + * The bound is the user's own membership, re-read on every exchange. That makes the + * project a selector over what the user can already reach rather than a privilege + * the connection holds — so a switch can never reach further than the browser could, + * and losing access to a project takes effect on the next request. + */ + const targetProjectId = params.requestedProjectId ?? subjectProjectId; + + const membership = await getOAuthProjectMembershipService().getForUser( + user, + targetProjectId, + ); + + if (isNil(membership)) { + throw invalidTarget('the requested project is not accessible'); + } + + const { accessToken, expiresIn } = await tokensService.mintExchangedApiToken({ + grant: { id: grant.id, userId: grant.userId, clientId: grant.clientId }, + scope: EXCHANGED_SCOPE, + projectId: membership.projectId, + }); + + await grantsService.touch(grant.id); + + return { + access_token: accessToken, + issued_token_type: ACCESS_TOKEN_TYPE, + token_type: 'Bearer', + expires_in: expiresIn, + scope: EXCHANGED_SCOPE, + }; +} diff --git a/packages/server/api/src/app/oauth/tokens.service.ts b/packages/server/api/src/app/oauth/tokens.service.ts new file mode 100644 index 0000000000..f39020067e --- /dev/null +++ b/packages/server/api/src/app/oauth/tokens.service.ts @@ -0,0 +1,425 @@ +import { logger } from '@openops/server-shared'; +import { isNil, openOpsId, User, UserStatus } from '@openops/shared'; +import { IsNull } from 'typeorm'; +import { repoFactory } from '../core/db/repo-factory'; +import { userService } from '../user/user-service'; +import { grantsService } from './grants.service'; +import { oauthConfig } from './oauth-config'; +import { generateOpaqueToken, sha256Hex } from './oauth-crypto'; +import { invalidGrant, invalidTarget } from './oauth-errors'; +import { + OAuthAuthorizationCode, + OAuthGrant, + OAuthPendingAuthorization, + OAuthRefreshToken, + OAuthTokenResponse, +} from './oauth-model'; +import { + OAuthAuthorizationCodeEntity, + OAuthRefreshTokenEntity, +} from './oauth.entity'; +import { verifyPkce } from './pkce'; +import { getOAuthProjectMembershipService } from './project-membership-factory'; +import { resolveResource } from './resource-registry'; +import { signingKeyService } from './signing-key.service'; + +const codeRepo = repoFactory( + OAuthAuthorizationCodeEntity, +); +const refreshTokenRepo = repoFactory( + OAuthRefreshTokenEntity, +); + +const AUTHORIZATION_CODE_TTL_MS = 60 * 1000; + +/** Same text for every redemption failure so nothing can be probed by trial. */ +const UNUSABLE_CODE = 'invalid or expired authorization code'; + +function isExpired(timestamp: string, now: number): boolean { + return new Date(timestamp).getTime() <= now; +} + +/** + * Re-checked on every redemption and rotation so deactivating a user takes + * effect without waiting for their tokens to expire. + */ +async function loadActiveUserOrThrow(userId: string): Promise { + const user = await userService.get({ id: userId }); + + if (isNil(user) || user.status !== UserStatus.ACTIVE) { + throw invalidGrant('the user for this authorization is no longer active'); + } + + return user; +} + +/** The project a new connection binds to. */ +async function resolveDefaultProjectId(user: User): Promise { + const membership = await getOAuthProjectMembershipService().getDefaultForUser( + user, + ); + + if (isNil(membership)) { + throw invalidGrant('the user has no accessible project'); + } + + return membership.projectId; +} + +/** + * Re-authorizes the project before minting. Access to a project can be withdrawn + * after a connection is made, and refreshing must not hand out a token for a + * project the user can no longer reach. + */ +async function authorizeProjectOrThrow( + user: User, + projectId: string, + wasRequested = false, +): Promise { + const membership = await getOAuthProjectMembershipService().getForUser( + user, + projectId, + ); + + if (isNil(membership)) { + // Two different failures. The client naming a project it may not have is + // `invalid_target` (RFC 8707) — a bad request it can correct. The connection's own + // project having become unreachable is `invalid_grant`: the authorization is stale + // and re-authorizing is the only fix. + throw wasRequested + ? invalidTarget('the requested project is not accessible') + : invalidGrant('the project for this authorization is not accessible'); + } + + return membership.projectId; +} + +async function mintAccessToken(params: { + grant: Pick; + audience: string; + scope: string; + projectId: string; + ttlSeconds: number; +}): Promise { + return signingKeyService.signAccessToken( + { + sub: params.grant.userId, + aud: params.audience, + client_id: params.grant.clientId, + scope: params.scope, + grant_id: params.grant.id, + project_id: params.projectId, + }, + params.ttlSeconds, + ); +} + +async function issueRefreshToken(params: { + grantId: string; + familyId: string; + clientId: string; + resource: string; + scope: string; + projectId: string; +}): Promise { + const token = generateOpaqueToken(); + const now = new Date(); + const expiresAt = new Date( + now.getTime() + oauthConfig.getRefreshTokenTtlDays() * 24 * 60 * 60 * 1000, + ); + + await refreshTokenRepo().insert({ + id: openOpsId(), + created: now.toISOString(), + updated: now.toISOString(), + tokenHash: sha256Hex(token), + grantId: params.grantId, + familyId: params.familyId, + clientId: params.clientId, + resource: params.resource, + scope: params.scope, + projectId: params.projectId, + expiresAt: expiresAt.toISOString(), + revokedAt: null, + }); + + return token; +} + +export type RedeemAuthorizationCodeParams = { + code: string; + clientId: string; + redirectUri: string; + codeVerifier: string; + resource: string; +}; + +export type RotateRefreshTokenParams = { + refreshToken: string; + clientId: string; + /** + * Switch the connection to another project the user belongs to. Omitted keeps it + * where it is. Membership is re-checked either way, so this cannot reach a project + * the user could not reach in the browser. + */ + requestedProjectId?: string; +}; + +export const tokensService = { + /** + * Issues a single-use code for an approved authorization request. The code is + * stored only as a hash, and every parameter the token endpoint must later + * re-check is copied from the already-validated pending record. + */ + async issueAuthorizationCode( + pending: OAuthPendingAuthorization, + userId: string, + ): Promise { + const code = generateOpaqueToken(); + const now = new Date(); + + await codeRepo().insert({ + id: openOpsId(), + created: now.toISOString(), + updated: now.toISOString(), + codeHash: sha256Hex(code), + clientId: pending.clientId, + userId, + redirectUri: pending.redirectUri, + codeChallenge: pending.codeChallenge, + resource: pending.resource, + scope: pending.scope, + expiresAt: new Date( + now.getTime() + AUTHORIZATION_CODE_TTL_MS, + ).toISOString(), + consumedAt: null, + }); + + return code; + }, + + async redeemAuthorizationCode( + params: RedeemAuthorizationCodeParams, + ): Promise { + const codeHash = sha256Hex(params.code); + + // Claim the code before validating anything else: the conditional update is + // what makes a replayed code fail even when two requests arrive together. + const claim = await codeRepo().update( + { codeHash, consumedAt: IsNull() }, + { consumedAt: new Date().toISOString() }, + ); + + if (claim.affected !== 1) { + throw invalidGrant(UNUSABLE_CODE); + } + + const codeRecord = await codeRepo().findOneBy({ codeHash }); + + if (!codeRecord || isExpired(codeRecord.expiresAt, Date.now())) { + throw invalidGrant(UNUSABLE_CODE); + } + + if ( + codeRecord.clientId !== params.clientId || + codeRecord.redirectUri !== params.redirectUri + ) { + throw invalidGrant(UNUSABLE_CODE); + } + + const resource = resolveResource(params.resource); + + if (!resource || resource.canonicalUri !== codeRecord.resource) { + throw invalidGrant(UNUSABLE_CODE); + } + + if (!verifyPkce(params.codeVerifier, codeRecord.codeChallenge)) { + throw invalidGrant(UNUSABLE_CODE); + } + + const user = await loadActiveUserOrThrow(codeRecord.userId); + // Where the connection starts. Recorded on the refresh token rather than the grant, + // because it is a property of the credential chain and changes when the client + // switches project. + const projectId = await resolveDefaultProjectId(user); + const grant = await grantsService.create({ + clientId: codeRecord.clientId, + userId: codeRecord.userId, + resourceId: resource.id, + }); + + const accessToken = await mintAccessToken({ + grant, + audience: resource.audience, + scope: codeRecord.scope, + projectId, + ttlSeconds: oauthConfig.getAccessTokenTtlSeconds(), + }); + + const refreshToken = await issueRefreshToken({ + grantId: grant.id, + familyId: openOpsId(), + clientId: grant.clientId, + resource: resource.canonicalUri, + scope: codeRecord.scope, + projectId, + }); + + return { + access_token: accessToken, + token_type: 'Bearer', + expires_in: oauthConfig.getAccessTokenTtlSeconds(), + scope: codeRecord.scope, + refresh_token: refreshToken, + }; + }, + + /** + * Rotates a refresh token (OAuth 2.1 §4.3.1). Presenting a token that was + * already rotated means either a replay or a stolen token racing the real + * client, and cannot be distinguished from the server's side — so the whole + * family is revoked and the connection has to be re-authorized. + */ + async rotateRefreshToken( + params: RotateRefreshTokenParams, + ): Promise { + const tokenHash = sha256Hex(params.refreshToken); + const existingToken = await refreshTokenRepo().findOneBy({ tokenHash }); + + if (!existingToken) { + throw invalidGrant('invalid refresh token'); + } + + // Everything that can be judged without consuming the token is judged first. + // Revoking on the way in would let one rejected request — a wrong client id, + // a momentary outage — destroy a working credential, and the client's natural + // retry would then look exactly like a replay. + if (existingToken.clientId !== params.clientId) { + throw invalidGrant('invalid refresh token'); + } + + if (isExpired(existingToken.expiresAt, Date.now())) { + throw invalidGrant('refresh token expired'); + } + + const grant = await grantsService.getActiveGrantOrThrow( + existingToken.grantId, + ); + const user = await loadActiveUserOrThrow(grant.userId); + // A refresh is where a connection changes project: the client names where it wants + // to be, and membership decides whether it may. Defaulting to the presented token's + // own project is what makes a plain renewal equivalent to the credential it + // replaces — falling back to the grant would quietly undo an earlier switch. + const projectId = await authorizeProjectOrThrow( + user, + params.requestedProjectId ?? existingToken.projectId, + params.requestedProjectId !== undefined, + ); + + const resource = resolveResource(existingToken.resource); + + if (!resource) { + throw invalidGrant( + 'the resource for this authorization no longer exists', + ); + } + + // Only now consume it. The conditional update is what makes rotation atomic: + // of two requests presenting the same token, exactly one proceeds. + const claim = await refreshTokenRepo().update( + { tokenHash, revokedAt: IsNull() }, + { revokedAt: new Date().toISOString() }, + ); + + if (claim.affected !== 1) { + // Already revoked. A deliberate revocation of the connection also revokes + // its tokens, so check that first: reporting it as a replay would + // misattribute the user's own action to an attack. + const grantSnapshot = await grantsService.getGrantSnapshot( + existingToken.grantId, + ); + + if (grantSnapshot?.status !== 'active') { + throw invalidGrant( + 'the authorization for this client has been revoked', + ); + } + + await tokensService.revokeFamily(existingToken.familyId); + logger.warn('OAuth refresh token reuse detected; family revoked', { + familyId: existingToken.familyId, + grantId: existingToken.grantId, + clientId: existingToken.clientId, + }); + throw invalidGrant('refresh token reuse detected'); + } + + const accessToken = await mintAccessToken({ + grant, + audience: resource.audience, + scope: existingToken.scope, + projectId, + ttlSeconds: oauthConfig.getAccessTokenTtlSeconds(), + }); + + const refreshToken = await issueRefreshToken({ + grantId: grant.id, + // Same family: rotation forms a chain, and reuse anywhere in it is fatal. + familyId: existingToken.familyId, + clientId: existingToken.clientId, + resource: existingToken.resource, + scope: existingToken.scope, + projectId, + }); + + return { + access_token: accessToken, + token_type: 'Bearer', + expires_in: oauthConfig.getAccessTokenTtlSeconds(), + scope: existingToken.scope, + refresh_token: refreshToken, + }; + }, + + async revokeFamily(familyId: string): Promise { + await refreshTokenRepo().update( + { familyId, revokedAt: IsNull() }, + { revokedAt: new Date().toISOString() }, + ); + }, + + /** RFC 7009: revoking any refresh token revokes the whole connection. */ + async revokeByRefreshToken(refreshToken: string): Promise { + const record = await refreshTokenRepo().findOneBy({ + tokenHash: sha256Hex(refreshToken), + }); + + if (!record) { + return; + } + + await grantsService.revoke(record.grantId); + }, + + /** + * The API-audience token handed to a resource server. `projectId` is explicit + * so the caller states which project the token is for and the claim, not any + * stored state, decides what it can act on. + */ + async mintExchangedApiToken(params: { + grant: Pick; + scope: string; + projectId: string; + }): Promise<{ accessToken: string; expiresIn: number }> { + const expiresIn = oauthConfig.getExchangeTokenTtlSeconds(); + const accessToken = await mintAccessToken({ + grant: params.grant, + audience: oauthConfig.getApiAudience(), + scope: params.scope, + projectId: params.projectId, + ttlSeconds: expiresIn, + }); + + return { accessToken, expiresIn }; + }, +}; diff --git a/packages/server/api/test/integration/ce/authentication/signup.test.ts b/packages/server/api/test/integration/ce/authentication/signup.test.ts index 3355c1a54c..104bc7be6e 100644 --- a/packages/server/api/test/integration/ce/authentication/signup.test.ts +++ b/packages/server/api/test/integration/ce/authentication/signup.test.ts @@ -1,5 +1,3 @@ -import { accessTokenManager } from '../../../../src/app/authentication/context/access-token-manager'; - const authUserMock = jest.fn().mockResolvedValue({ token: 'token', refresh_token: 'refresh_token', @@ -38,6 +36,7 @@ jest.mock('../../../../src/app/openops-tables/index', () => ({ import { PrincipalType, UserStatus } from '@openops/shared'; import { FastifyInstance } from 'fastify'; import { StatusCodes } from 'http-status-codes'; +import { accessTokenManager } from '../../../../src/app/authentication/context/access-token-manager'; import { databaseConnection } from '../../../../src/app/database/database-connection'; import { setupServer } from '../../../../src/app/server'; import { generateMockToken } from '../../../helpers/auth'; diff --git a/packages/server/api/test/integration/ce/oauth/oauth-consumption.test.ts b/packages/server/api/test/integration/ce/oauth/oauth-consumption.test.ts new file mode 100644 index 0000000000..dbec572798 --- /dev/null +++ b/packages/server/api/test/integration/ce/oauth/oauth-consumption.test.ts @@ -0,0 +1,421 @@ +import { encryptUtils } from '@openops/server-shared'; +import { UserStatus } from '@openops/shared'; +import { IsNull } from 'typeorm'; +import { databaseConnection } from '../../../../src/app/database/database-connection'; +import { grantsService } from '../../../../src/app/oauth/grants.service'; +import { oauthCleanupJobHandler } from '../../../../src/app/oauth/oauth-cleanup-job'; +import { oauthConfig } from '../../../../src/app/oauth/oauth-config'; +import { pendingAuthorizationService } from '../../../../src/app/oauth/pending-authorization.service'; +import { signingKeyService } from '../../../../src/app/oauth/signing-key.service'; +import { tokensService } from '../../../../src/app/oauth/tokens.service'; +import { + createMockOrganization, + createMockProject, + createMockUser, +} from '../../../helpers/mocks'; + +/** + * Exercises the guarantees that unit tests with in-memory repositories cannot + * observe: that single-use consumption really is a conditional UPDATE the database + * serialises, that `LessThan` and `IsNull` are distinct predicates, and that the + * cleanup job's query-builder SQL deletes the rows it should and no others. + * + * Runs under the repo's integration harness, which uses SQLite with schema + * synchronisation. That is not the production driver — OAuth targets Postgres — + * but it does replace hand-written mocks with a real ORM and real SQL, which is + * where the risk was. + */ + +const ISSUER = 'http://localhost:3000'; +const MCP_RESOURCE = 'http://localhost:3020/mcp'; +const CODE_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; +const CODE_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; +const CLIENT_ID = 'oauthitclient00000001'; +const OTHER_CLIENT_ID = 'oauthitclient00000002'; +const LONG_AGO = new Date(Date.now() - 400 * 24 * 3600 * 1000).toISOString(); + +let userId: string; +let projectId: string; + +const repo = (table: string) => databaseConnection().getRepository(table); + +async function seedClients(): Promise { + for (const id of [CLIENT_ID, OTHER_CLIENT_ID]) { + await repo('oauth_client').save({ + id, + clientName: 'Integration Test Client', + redirectUris: ['https://client.example/cb'], + grantTypes: ['authorization_code', 'refresh_token'], + tokenEndpointAuthMethod: 'none', + clientSecretHash: null, + scope: '', + }); + } +} + +async function newPendingRequest(): Promise { + return pendingAuthorizationService.create({ + clientId: CLIENT_ID, + redirectUri: 'https://client.example/cb', + codeChallenge: CODE_CHALLENGE, + resource: MCP_RESOURCE, + scope: 'mcp', + state: null, + }); +} + +async function newAuthorizationCode(): Promise { + const requestId = await newPendingRequest(); + const pending = await pendingAuthorizationService.get(requestId); + + return tokensService.issueAuthorizationCode(pending, userId); +} + +function redeemParams(code: string) { + return { + code, + clientId: CLIENT_ID, + redirectUri: 'https://client.example/cb', + codeVerifier: CODE_VERIFIER, + resource: MCP_RESOURCE, + }; +} + +async function issueConnection(): Promise { + const code = await newAuthorizationCode(); + const response = await tokensService.redeemAuthorizationCode( + redeemParams(code), + ); + + return response.refresh_token as string; +} + +beforeAll(async () => { + encryptUtils.loadEncryptionKey(); + await databaseConnection().initialize(); + + jest.spyOn(oauthConfig, 'getIssuerUrl').mockReturnValue(ISSUER); + jest.spyOn(oauthConfig, 'getApiAudience').mockReturnValue(ISSUER); + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(MCP_RESOURCE); + jest.spyOn(oauthConfig, 'getAccessTokenTtlSeconds').mockReturnValue(900); + jest.spyOn(oauthConfig, 'getRefreshTokenTtlDays').mockReturnValue(30); + jest.spyOn(oauthConfig, 'getExchangeTokenTtlSeconds').mockReturnValue(300); + jest.spyOn(oauthConfig, 'getSigningKeyPemPath').mockReturnValue(undefined); + + await signingKeyService.ensureSigningKey(); + + const user = createMockUser({ + email: `oauth-it-${Date.now()}@openops.com`, + verified: true, + status: UserStatus.ACTIVE, + }); + await repo('user').save(user); + + const organization = createMockOrganization({ ownerId: user.id }); + await repo('organization').save(organization); + await repo('user').update(user.id, { organizationId: organization.id }); + + const project = createMockProject({ + ownerId: user.id, + organizationId: organization.id, + }); + await repo('project').save(project); + + userId = user.id; + projectId = project.id; +}); + +afterAll(async () => { + await databaseConnection().destroy(); +}); + +async function clearTable(table: string): Promise { + await repo(table).createQueryBuilder().delete().execute(); +} + +async function updateAll( + table: string, + patch: Record, +): Promise { + await repo(table).createQueryBuilder().update().set(patch).execute(); +} + +beforeEach(async () => { + for (const table of [ + 'oauth_refresh_token', + 'oauth_authorization_code', + 'oauth_pending_authorization', + 'oauth_grant', + 'oauth_client', + ]) { + await clearTable(table); + } + grantsService.clearSnapshotCacheForTests(); + signingKeyService.clearKeyCacheForTests(); + await seedClients(); +}); + +describe('authorization code consumption', () => { + it('lets exactly one of many concurrent redemptions succeed', async () => { + const code = await newAuthorizationCode(); + + const results = await Promise.allSettled( + Array.from({ length: 8 }, () => + tokensService.redeemAuthorizationCode(redeemParams(code)), + ), + ); + + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + // One connection and one refresh token, not eight. + expect(await repo('oauth_grant').count()).toBe(1); + expect(await repo('oauth_refresh_token').count()).toBe(1); + }); + + it('rejects a sequential replay and issues nothing further', async () => { + const code = await newAuthorizationCode(); + await tokensService.redeemAuthorizationCode(redeemParams(code)); + + await expect( + tokensService.redeemAuthorizationCode(redeemParams(code)), + ).rejects.toThrow('invalid or expired authorization code'); + expect(await repo('oauth_refresh_token').count()).toBe(1); + }); + + it('rejects a code whose expiry has passed', async () => { + const code = await newAuthorizationCode(); + await updateAll('oauth_authorization_code', { + expiresAt: new Date(Date.now() - 1000).toISOString(), + }); + + await expect( + tokensService.redeemAuthorizationCode(redeemParams(code)), + ).rejects.toThrow('invalid or expired authorization code'); + expect(await repo('oauth_refresh_token').count()).toBe(0); + }); + + it('creates an independent connection per authorization for one client', async () => { + await tokensService.redeemAuthorizationCode( + redeemParams(await newAuthorizationCode()), + ); + await tokensService.redeemAuthorizationCode( + redeemParams(await newAuthorizationCode()), + ); + + expect(await repo('oauth_grant').count()).toBe(2); + }); +}); + +describe('pending authorization consumption', () => { + it('lets exactly one of many concurrent decisions succeed', async () => { + const requestId = await newPendingRequest(); + + const results = await Promise.allSettled( + Array.from({ length: 8 }, () => + pendingAuthorizationService.consume(requestId), + ), + ); + + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + }); + + it('refuses an expired request', async () => { + const requestId = await newPendingRequest(); + await repo('oauth_pending_authorization').update( + { id: requestId }, + { expiresAt: new Date(Date.now() - 1000).toISOString() }, + ); + + await expect(pendingAuthorizationService.get(requestId)).rejects.toThrow( + 'unknown or expired authorization request', + ); + await expect( + pendingAuthorizationService.consume(requestId), + ).rejects.toThrow('unknown or expired authorization request'); + }); +}); + +describe('refresh token rotation', () => { + it('lets exactly one of many concurrent rotations succeed', async () => { + const refreshToken = await issueConnection(); + + const results = await Promise.allSettled( + Array.from({ length: 8 }, () => + tokensService.rotateRefreshToken({ refreshToken, clientId: CLIENT_ID }), + ), + ); + + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + }); + + it('revokes the whole family when a rotated token is replayed', async () => { + const original = await issueConnection(); + await tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: CLIENT_ID, + }); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: CLIENT_ID, + }), + ).rejects.toThrow('reuse detected'); + + expect( + await repo('oauth_refresh_token').count({ + where: { revokedAt: IsNull() }, + }), + ).toBe(0); + }); + + it('leaves the token usable when the request is rejected for another reason', async () => { + const refreshToken = await issueConnection(); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken, + clientId: OTHER_CLIENT_ID, + }), + ).rejects.toThrow('invalid refresh token'); + + await expect( + tokensService.rotateRefreshToken({ refreshToken, clientId: CLIENT_ID }), + ).resolves.toMatchObject({ token_type: 'Bearer' }); + }); +}); + +describe('revocation', () => { + it('cascades to the refresh tokens of that connection only', async () => { + await issueConnection(); + await issueConnection(); + + const grants = await repo('oauth_grant').find({ + order: { created: 'ASC' }, + }); + await grantsService.revoke(grants[0].id); + + const rows = await repo('oauth_refresh_token').find(); + const revokedFor = (grantId: string) => + rows.find((row) => row.grantId === grantId)?.revokedAt !== null; + + expect(revokedFor(grants[0].id)).toBe(true); + expect(revokedFor(grants[1].id)).toBe(false); + }); + + it('stops a revoked connection from refreshing', async () => { + const refreshToken = await issueConnection(); + const [grant] = await repo('oauth_grant').find(); + + await grantsService.revoke(grant.id); + + await expect( + tokensService.rotateRefreshToken({ refreshToken, clientId: CLIENT_ID }), + ).rejects.toThrow('has been revoked'); + }); +}); + +describe('cleanup job', () => { + it('deletes expired records and leaves live ones usable', async () => { + // Issuing a code also leaves its own (still live) pending record behind. + const liveCode = await newAuthorizationCode(); + const liveRequest = await newPendingRequest(); + const expiredRequest = await newPendingRequest(); + const liveCount = await repo('oauth_pending_authorization').count(); + + await repo('oauth_pending_authorization').update( + { id: expiredRequest }, + { expiresAt: new Date(Date.now() - 60_000).toISOString() }, + ); + + await oauthCleanupJobHandler(); + + expect(await repo('oauth_pending_authorization').count()).toBe( + liveCount - 1, + ); + await expect( + pendingAuthorizationService.get(liveRequest), + ).resolves.toMatchObject({ clientId: CLIENT_ID }); + // The live code is untouched and still redeemable. + await expect( + tokensService.redeemAuthorizationCode(redeemParams(liveCode)), + ).resolves.toMatchObject({ token_type: 'Bearer' }); + }); + + it('deletes an expired authorization code', async () => { + await newAuthorizationCode(); + await updateAll('oauth_authorization_code', { + expiresAt: new Date(Date.now() - 60_000).toISOString(), + }); + + await oauthCleanupJobHandler(); + + expect(await repo('oauth_authorization_code').count()).toBe(0); + }); + + it('removes a connection only once it has no usable refresh token left', async () => { + await issueConnection(); + await updateAll('oauth_grant', { created: LONG_AGO, lastUsedAt: null }); + + // A live refresh token still exists, so the connection must survive. + await oauthCleanupJobHandler(); + expect(await repo('oauth_grant').count()).toBe(1); + + await updateAll('oauth_refresh_token', { revokedAt: LONG_AGO }); + + await oauthCleanupJobHandler(); + expect(await repo('oauth_grant').count()).toBe(0); + }); + + it('keeps a recently used connection even with no live refresh token', async () => { + await issueConnection(); + // Old row, but used moments ago: the cutoff is on last use, not on age. + await updateAll('oauth_grant', { + created: LONG_AGO, + lastUsedAt: new Date().toISOString(), + }); + await updateAll('oauth_refresh_token', { revokedAt: LONG_AGO }); + + await oauthCleanupJobHandler(); + + // `lastUsedAt` is recent, so this is an active connection despite its age. + expect(await repo('oauth_grant').count()).toBe(1); + }); + + it('keeps a client a connection still references, and deletes one nothing does', async () => { + await issueConnection(); + await updateAll('oauth_client', { created: LONG_AGO }); + + await oauthCleanupJobHandler(); + + const remaining = await repo('oauth_client').find(); + expect(remaining.map((row) => row.id)).toEqual([CLIENT_ID]); + }); +}); + +describe('signing keys', () => { + it('keeps a token verifiable after its key starts retiring', async () => { + const token = await signingKeyService.signAccessToken( + { + sub: userId, + aud: ISSUER, + client_id: CLIENT_ID, + scope: 'api', + grant_id: 'grant000000000000001', + project_id: projectId, + }, + 900, + ); + + await repo('oauth_signing_key').update( + { status: 'active' }, + { status: 'retiring' }, + ); + signingKeyService.clearKeyCacheForTests(); + await signingKeyService.ensureSigningKey(); + + await expect( + signingKeyService.verifyAccessToken(token, ISSUER), + ).resolves.toMatchObject({ sub: userId }); + expect((await signingKeyService.getJwks()).keys).toHaveLength(2); + }); +}); diff --git a/packages/server/api/test/unit/ai/openops-tools.test.ts b/packages/server/api/test/unit/ai/openops-tools.test.ts index 6a93497170..2e36b231c9 100644 --- a/packages/server/api/test/unit/ai/openops-tools.test.ts +++ b/packages/server/api/test/unit/ai/openops-tools.test.ts @@ -135,62 +135,15 @@ describe('getOpenOpsTools', () => { }, }; - const filteredSchema = { - openapi: '3.1', - paths: { - '/v1/files/{fileId}': { - get: { operationId: 'getFile' }, - }, - '/v1/flow-versions/': { - get: { operationId: 'getFlowVersions' }, - }, - '/v1/flows/': { - get: { operationId: 'getFlows' }, - }, - '/v1/flows/count': { - get: { operationId: 'getFlowsCount' }, - }, - '/v1/flows/{id}': { - get: { operationId: 'getFlow' }, - }, - '/v1/blocks/categories': { - get: { operationId: 'getBlockCategories' }, - }, - '/v1/blocks/': { - get: { operationId: 'getBlocks' }, - }, - '/v1/blocks/{scope}/{name}': { - get: { operationId: 'getBlockScopeName' }, - }, - '/v1/blocks/{name}': { - get: { operationId: 'getBlockName' }, - }, - '/v1/flow-runs/': { - get: { operationId: 'getFlowRuns' }, - }, - '/v1/flow-runs/{id}': { - get: { operationId: 'getFlowRun' }, - }, - '/v1/flow-runs/{id}/retry': { - post: { operationId: 'retryFlowRun' }, - }, - '/v1/app-connections/': { - get: { operationId: 'getAppConnections' }, - patch: { operationId: 'patchAppConnection' }, - }, - '/v1/app-connections/{id}': { - get: { operationId: 'getAppConnectionById' }, - }, - '/v1/app-connections/metadata': { - get: { operationId: 'getAppConnectionsMetadata' }, - }, - }, - }; - const mockApp = { swagger: jest.fn().mockReturnValue(mockOpenApiSchema), } as unknown as FastifyInstance; + const writtenRoutes = (): { path: string; methods: string[] }[] => { + const [, contents] = jest.mocked(fs.writeFile).mock.calls[0]; + return JSON.parse(contents as string).routes; + }; + beforeEach(() => { jest.clearAllMocks(); @@ -208,17 +161,42 @@ describe('getOpenOpsTools', () => { networkUtlsMock.getInternalApiUrl.mockReturnValue(mockApiBaseUrl); }); - it('should write the filtered OpenAPI schema to a file once and reuse it later', async () => { - const mockClient = { + // The written path is cached for the life of the process, so only the first call in + // this file performs the write. Both assertions about its contents live here. + it('should write only the allowed operations the API actually exposes', async () => { + createMcpClientMock.mockResolvedValue({ tools: jest.fn().mockResolvedValue(mockTools), - }; - createMcpClientMock.mockResolvedValue(mockClient); + }); await getOpenOpsTools(mockApp, 'auth-1'); - expect(fs.writeFile).toHaveBeenCalledWith( - path.join('/tmp', 'openapi-schema.json'), - JSON.stringify(filteredSchema), - 'utf-8', + + const [target] = jest.mocked(fs.writeFile).mock.calls[0]; + expect(target).toBe(path.join('/tmp', 'openops-mcp-routes.json')); + + // `/v1/blocks/options` is allow-listed but missing from this document. It must be + // left out: the MCP server refuses to start on an operation it cannot find, which + // would cost every other tool too. + expect(writtenRoutes()).toEqual([ + { path: '/v1/files/{fileId}', methods: ['get'] }, + { path: '/v1/flow-versions/', methods: ['get'] }, + { path: '/v1/flows/', methods: ['get'] }, + { path: '/v1/flows/count', methods: ['get'] }, + { path: '/v1/flows/{id}', methods: ['get'] }, + { path: '/v1/blocks/categories', methods: ['get'] }, + { path: '/v1/blocks/', methods: ['get'] }, + { path: '/v1/blocks/{scope}/{name}', methods: ['get'] }, + { path: '/v1/blocks/{name}', methods: ['get'] }, + { path: '/v1/flow-runs/', methods: ['get'] }, + { path: '/v1/flow-runs/{id}', methods: ['get'] }, + { path: '/v1/flow-runs/{id}/retry', methods: ['post'] }, + { path: '/v1/app-connections/', methods: ['get', 'patch'] }, + { path: '/v1/app-connections/{id}', methods: ['get'] }, + { path: '/v1/app-connections/metadata', methods: ['get'] }, + ]); + + expect(loggerMock.warn).toHaveBeenCalledWith( + 'Skipping MCP operations the API does not expose', + { path: '/v1/blocks/options', requested: ['post'], served: [] }, ); await getOpenOpsTools(mockApp, 'auth-2'); @@ -247,9 +225,10 @@ describe('getOpenOpsTools', () => { command: `${mockBasePath}/.venv/bin/python`, args: [`${mockBasePath}/main.py`], env: expect.objectContaining({ - OPENAPI_SCHEMA_PATH: expect.any(String), + MCP_TRANSPORT: 'stdio', AUTH_TOKEN: 'auth-service-token', - API_BASE_URL: mockApiBaseUrl, + OPENOPS_MCP_ROUTES: path.join('/tmp', 'openops-mcp-routes.json'), + OPENOPS_API_URL: mockApiBaseUrl, OPENOPS_MCP_SERVER_PATH: mockBasePath, LOGZIO_TOKEN: 'test-logzio-token', ENVIRONMENT: 'test-environment', diff --git a/packages/server/api/test/unit/oauth/authorize-validation.test.ts b/packages/server/api/test/unit/oauth/authorize-validation.test.ts new file mode 100644 index 0000000000..0187ad2122 --- /dev/null +++ b/packages/server/api/test/unit/oauth/authorize-validation.test.ts @@ -0,0 +1,232 @@ +import crypto from 'node:crypto'; +import { + AuthorizeQuery, + validateAuthorizeRequest, +} from '../../../src/app/oauth/authorize-validation'; +import { oauthConfig } from '../../../src/app/oauth/oauth-config'; +import { OAuthClient } from '../../../src/app/oauth/oauth-model'; + +const API_URI = 'https://ops.example.com/api'; +const MCP_URI = 'https://ops.example.com/mcp'; +const REGISTERED = 'https://client.example/cb'; +const CHALLENGE = crypto + .createHash('sha256') + .update('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk') + .digest('base64url'); + +const CLIENT: OAuthClient = { + id: 'client-1', + created: new Date().toISOString(), + updated: new Date().toISOString(), + clientName: 'Claude Code', + redirectUris: [REGISTERED, 'http://127.0.0.1:1234/callback'], + grantTypes: ['authorization_code', 'refresh_token'], + tokenEndpointAuthMethod: 'none', + clientSecretHash: null, +}; + +function query(overrides: Record = {}): AuthorizeQuery { + return { + client_id: 'client-1', + redirect_uri: REGISTERED, + response_type: 'code', + code_challenge: CHALLENGE, + code_challenge_method: 'S256', + resource: MCP_URI, + ...overrides, + }; +} + +describe('validateAuthorizeRequest', () => { + beforeEach(() => { + jest.spyOn(oauthConfig, 'getApiAudience').mockReturnValue(API_URI); + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(MCP_URI); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('accepts a well-formed request and returns the validated values', () => { + expect(validateAuthorizeRequest(query({ state: 'xyz' }), CLIENT)).toEqual({ + kind: 'ok', + resource: expect.objectContaining({ id: 'mcp', canonicalUri: MCP_URI }), + scope: 'mcp', + redirectUri: REGISTERED, + codeChallenge: CHALLENGE, + state: 'xyz', + }); + }); + + it('defaults the scope to what the resource offers', () => { + const result = validateAuthorizeRequest(query(), CLIENT); + + expect(result).toMatchObject({ kind: 'ok', scope: 'mcp', state: null }); + }); + + describe('refuses to redirect when the destination cannot be trusted', () => { + // This is the open-redirect boundary: a `render_error` must never be turned + // into a redirect by the caller. + it('renders rather than redirects for an unknown client', () => { + expect(validateAuthorizeRequest(query(), null)).toEqual({ + kind: 'render_error', + error: 'invalid_client', + description: 'Unknown client.', + }); + }); + + it.each([ + ['an unregistered destination', 'https://attacker.example/steal'], + ['a path the client did not register', 'https://client.example/other'], + ['userinfo smuggled in', 'https://user:pass@client.example/cb'], + ['a fragment appended', `${REGISTERED}#tail`], + ['a missing value', undefined], + ['a non-string value', { evil: true }], + ])('renders rather than redirects for %s', (_label, redirectUri) => { + const result = validateAuthorizeRequest( + query({ redirect_uri: redirectUri }), + CLIENT, + ); + + expect(result.kind).toBe('render_error'); + }); + }); + + describe('redirects the error back to the client once the destination is known good', () => { + it.each([ + [ + 'a missing response_type', + { response_type: undefined }, + 'unsupported_response_type', + ], + [ + 'an implicit response_type', + { response_type: 'token' }, + 'unsupported_response_type', + ], + ['no PKCE challenge', { code_challenge: undefined }, 'invalid_request'], + [ + 'a malformed PKCE challenge', + { code_challenge: 'too-short' }, + 'invalid_request', + ], + [ + 'a plain PKCE method', + { code_challenge_method: 'plain' }, + 'invalid_request', + ], + [ + 'a missing PKCE method', + { code_challenge_method: undefined }, + 'invalid_request', + ], + ['no resource', { resource: undefined }, 'invalid_target'], + [ + 'an unknown resource', + { resource: 'https://elsewhere.example' }, + 'invalid_target', + ], + [ + 'a scope the resource does not offer', + { scope: 'api' }, + 'invalid_scope', + ], + ['an unknown scope', { scope: 'admin' }, 'invalid_scope'], + ])('%s', (_label, overrides, expectedError) => { + const result = validateAuthorizeRequest(query(overrides), CLIENT); + + expect(result).toMatchObject({ + kind: 'redirect_error', + error: expectedError, + redirectUri: REGISTERED, + }); + }); + + it('rejects an oversized state instead of letting it reach storage', () => { + const result = validateAuthorizeRequest( + query({ state: 's'.repeat(2049) }), + CLIENT, + ); + + expect(result).toMatchObject({ + kind: 'redirect_error', + error: 'invalid_request', + }); + // Not echoed back, since the value is what was rejected. + expect(result).toMatchObject({ state: null }); + }); + + it('accepts a large but permitted state, because clients put blobs there', () => { + const state = 's'.repeat(2048); + + expect(validateAuthorizeRequest(query({ state }), CLIENT)).toMatchObject({ + kind: 'ok', + state, + }); + }); + + it('echoes the state alongside the error so the client can correlate it', () => { + const result = validateAuthorizeRequest( + query({ response_type: 'token', state: 'correlate-me' }), + CLIENT, + ); + + expect(result).toMatchObject({ + kind: 'redirect_error', + state: 'correlate-me', + }); + }); + }); + + describe('non-string parameters', () => { + // A form/query parser can turn `scope[x]=1` into an object; treating that as + // a string would reach the database and surface as a 500. + it.each([ + ['response_type', { response_type: ['code'] }], + ['code_challenge', { code_challenge: { v: CHALLENGE } }], + ['code_challenge_method', { code_challenge_method: ['S256'] }], + ['resource', { resource: { v: MCP_URI } }], + ['scope', { scope: ['mcp'] }], + ])( + 'rejects a structured %s rather than substituting a default', + (_l, o) => { + const result = validateAuthorizeRequest(query(o), CLIENT); + + expect(result.kind).toBe('redirect_error'); + }, + ); + + it('rejects a structured state rather than storing or ignoring it', () => { + const result = validateAuthorizeRequest( + query({ state: { evil: true } }), + CLIENT, + ); + + expect(result).toMatchObject({ + kind: 'redirect_error', + error: 'invalid_request', + }); + }); + }); + + it('collapses duplicate scopes so a repeat cannot inflate what is stored', () => { + const result = validateAuthorizeRequest( + query({ scope: Array(60).fill('mcp').join(' ') }), + CLIENT, + ); + + expect(result).toMatchObject({ kind: 'ok', scope: 'mcp' }); + }); + + it('matches a loopback redirect on any port, as native clients require', () => { + const result = validateAuthorizeRequest( + query({ redirect_uri: 'http://127.0.0.1:59999/callback' }), + CLIENT, + ); + + expect(result).toMatchObject({ + kind: 'ok', + redirectUri: 'http://127.0.0.1:59999/callback', + }); + }); +}); diff --git a/packages/server/api/test/unit/oauth/available-projects.test.ts b/packages/server/api/test/unit/oauth/available-projects.test.ts new file mode 100644 index 0000000000..579b2cb08d --- /dev/null +++ b/packages/server/api/test/unit/oauth/available-projects.test.ts @@ -0,0 +1,70 @@ +const userGetMock = jest.fn(); +const projectGetOneMock = jest.fn(); +const listForUserMock = jest.fn(); + +jest.mock('../../../src/app/user/user-service', () => ({ + userService: { get: userGetMock }, +})); + +jest.mock('../../../src/app/project/project-service', () => ({ + projectService: { getOne: projectGetOneMock }, +})); + +jest.mock('../../../src/app/oauth/project-membership-factory', () => ({ + getOAuthProjectMembershipService: () => ({ + listForUser: listForUserMock, + }), +})); + +import { listAvailableProjects } from '../../../src/app/oauth/available-projects'; + +const USER = { id: 'user-1', organizationId: 'org-1' }; + +describe('listAvailableProjects', () => { + beforeEach(() => { + jest.clearAllMocks(); + userGetMock.mockResolvedValue(USER); + listForUserMock.mockResolvedValue([ + { projectId: 'proj-1', organizationId: 'org-1', projectRole: 'ADMIN' }, + { projectId: 'proj-2', organizationId: 'org-1', projectRole: 'ADMIN' }, + ]); + projectGetOneMock.mockImplementation((id: string) => + Promise.resolve({ + id, + displayName: id === 'proj-1' ? 'Cloud Ops' : 'Data', + }), + ); + }); + + it('names every project the connection may switch to', async () => { + await expect(listAvailableProjects('user-1')).resolves.toEqual([ + { projectId: 'proj-1', projectName: 'Cloud Ops' }, + { projectId: 'proj-2', projectName: 'Data' }, + ]); + }); + + it('asks the membership service, not the project table, what is reachable', async () => { + await listAvailableProjects('user-1'); + + // Membership is the authority. Listing projects some other way would let a client + // see, and try to switch into, projects it has no claim on. + expect(listForUserMock).toHaveBeenCalledWith(USER); + }); + + it('returns nothing when the user cannot be found', async () => { + userGetMock.mockResolvedValue(null); + + await expect(listAvailableProjects('user-1')).resolves.toEqual([]); + expect(listForUserMock).not.toHaveBeenCalled(); + }); + + it('keeps a project whose name cannot be read', async () => { + projectGetOneMock.mockResolvedValue(null); + + // Still switchable — an unreadable display name is not a reason to hide it. + await expect(listAvailableProjects('user-1')).resolves.toEqual([ + { projectId: 'proj-1', projectName: 'proj-1' }, + { projectId: 'proj-2', projectName: 'proj-2' }, + ]); + }); +}); diff --git a/packages/server/api/test/unit/oauth/canonical-url.test.ts b/packages/server/api/test/unit/oauth/canonical-url.test.ts new file mode 100644 index 0000000000..5c9fa0dbb5 --- /dev/null +++ b/packages/server/api/test/unit/oauth/canonical-url.test.ts @@ -0,0 +1,36 @@ +import { stripTrailingSlashes } from '../../../src/app/oauth/canonical-url'; + +describe('stripTrailingSlashes', () => { + it.each([ + ['https://ops.example.com/', 'https://ops.example.com'], + ['https://ops.example.com///', 'https://ops.example.com'], + ['https://ops.example.com', 'https://ops.example.com'], + ['https://ops.example.com/api/v1//', 'https://ops.example.com/api/v1'], + ['/v1/', '/v1'], + ['/', ''], + ['///', ''], + ['', ''], + ])('normalizes %j to %j', (input, expected) => { + expect(stripTrailingSlashes(input)).toBe(expected); + }); + + it('leaves slashes that are not at the end alone', () => { + expect(stripTrailingSlashes('https://a.example//b//c')).toBe( + 'https://a.example//b//c', + ); + }); + + it('stays fast on a long run of slashes', () => { + // The `/\/+$/` this replaced is quadratic here: ~145 ms at 20k slashes, ~8.8 s at + // 160k. One caller normalizes the client-supplied `resource` on public endpoints, so + // that was reachable from an unauthenticated request. The budget is ~30,000× the + // measured time, which is loose enough for a shared CI runner and still nowhere near + // the seconds the regex took. + const pathological = '/'.repeat(200_000) + 'x'; + + const started = Date.now(); + expect(stripTrailingSlashes(pathological)).toBe(pathological); + + expect(Date.now() - started).toBeLessThan(1000); + }); +}); diff --git a/packages/server/api/test/unit/oauth/clients.service.test.ts b/packages/server/api/test/unit/oauth/clients.service.test.ts new file mode 100644 index 0000000000..875596ca02 --- /dev/null +++ b/packages/server/api/test/unit/oauth/clients.service.test.ts @@ -0,0 +1,520 @@ +type ClientRow = Record; + +const clientRows: ClientRow[] = []; + +jest.mock('../../../src/app/core/db/repo-factory', () => ({ + repoFactory: () => () => ({ + findOneBy: async (query: { id: string }) => + clientRows.find((row) => row.id === query.id) ?? null, + insert: async (row: ClientRow) => { + if (clientRows.some((existing) => existing.id === row.id)) { + const error = new Error('duplicate key') as Error & { code: string }; + error.code = '23505'; + throw error; + } + clientRows.push(row); + }, + update: async (criteria: ClientRow, patch: ClientRow) => { + const targets = clientRows.filter((row) => row.id === criteria.id); + for (const target of targets) { + Object.assign(target, patch); + } + return { affected: targets.length }; + }, + save: async (row: ClientRow) => { + const index = clientRows.findIndex((existing) => existing.id === row.id); + if (index >= 0) { + clientRows[index] = { ...clientRows[index], ...row }; + return clientRows[index]; + } + clientRows.push(row); + return row; + }, + }), +})); + +import { + clientsService, + RS_CLIENT_ID, + TOKEN_EXCHANGE_GRANT, +} from '../../../src/app/oauth/clients.service'; +import { oauthConfig } from '../../../src/app/oauth/oauth-config'; +import { sha256Hex } from '../../../src/app/oauth/oauth-crypto'; +import { OAuthError } from '../../../src/app/oauth/oauth-errors'; +import { OAuthClient } from '../../../src/app/oauth/oauth-model'; + +const RS_SECRET = 'a'.repeat(48); + +const validMetadata = () => ({ + client_name: 'Test MCP Client', + redirect_uris: ['https://client.example.com/callback'], +}); + +const basicHeader = (clientId: string, secret: string): string => + `Basic ${Buffer.from(`${clientId}:${secret}`).toString('base64')}`; + +const storedRow = (id: string): ClientRow => { + const row = clientRows.find((candidate) => candidate.id === id); + if (!row) { + throw new Error(`expected a stored client row for ${id}`); + } + return row; +}; + +describe('clientsService', () => { + beforeEach(() => { + clientRows.length = 0; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('registerClient', () => { + it('registers a public client with defaults and no secret', async () => { + const response = await clientsService.registerClient(validMetadata()); + + expect(response.client_id).toEqual(expect.any(String)); + expect(response.client_id).toHaveLength(21); + expect(response.client_name).toBe('Test MCP Client'); + expect(response.redirect_uris).toEqual([ + 'https://client.example.com/callback', + ]); + expect(response.grant_types).toEqual([ + 'authorization_code', + 'refresh_token', + ]); + expect(response.token_endpoint_auth_method).toBe('none'); + expect(response.client_id_issued_at).toBeLessThanOrEqual( + Math.floor(Date.now() / 1000), + ); + + expect(JSON.stringify(response)).not.toContain('client_secret'); + expect( + Object.keys(response).filter((key) => key.includes('secret')), + ).toEqual([]); + + const row = storedRow(response.client_id); + expect(row.clientSecretHash).toBeNull(); + expect(row.tokenEndpointAuthMethod).toBe('none'); + expect(row.grantTypes).toEqual(['authorization_code', 'refresh_token']); + // No client-level usage column: usage is tracked per connection on the grant. + expect('lastUsedAt' in row).toBe(false); + // And no scope: what a token gets is decided by the resource it names, so + // storing or echoing a requested scope would be a second, unread answer. + expect('scope' in row).toBe(false); + expect('scope' in response).toBe(false); + }); + + it('persists an explicitly requested subset of grant types', async () => { + const response = await clientsService.registerClient({ + ...validMetadata(), + grant_types: ['authorization_code'], + }); + + expect(response.grant_types).toEqual(['authorization_code']); + expect(storedRow(response.client_id).grantTypes).toEqual([ + 'authorization_code', + ]); + }); + + it('ignores a requested scope rather than storing it', async () => { + const response = await clientsService.registerClient({ + ...validMetadata(), + scope: 'mcp api something-invented', + }); + + // Accepted, because refusing a field we simply do not use would be worse for + // clients that send it. It is neither stored nor echoed. + expect('scope' in storedRow(response.client_id)).toBe(false); + expect('scope' in response).toBe(false); + }); + + it('rejects a missing client_name', async () => { + await expect( + clientsService.registerClient({ + redirect_uris: ['https://client.example.com/callback'], + }), + ).rejects.toThrow(OAuthError); + expect(clientRows).toHaveLength(0); + }); + + it('rejects an empty client_name', async () => { + await expect( + clientsService.registerClient({ ...validMetadata(), client_name: '' }), + ).rejects.toThrow('invalid_client_metadata'); + }); + + it('rejects a client_name over 128 characters', async () => { + await expect( + clientsService.registerClient({ + ...validMetadata(), + client_name: 'n'.repeat(129), + }), + ).rejects.toThrow('invalid_client_metadata'); + }); + + it('rejects missing redirect_uris', async () => { + await expect( + clientsService.registerClient({ client_name: 'Test MCP Client' }), + ).rejects.toThrow('invalid_redirect_uri'); + }); + + it('rejects an empty redirect_uris array', async () => { + await expect( + clientsService.registerClient({ + ...validMetadata(), + redirect_uris: [], + }), + ).rejects.toThrow('invalid_redirect_uri'); + }); + + it('rejects more than ten redirect_uris', async () => { + await expect( + clientsService.registerClient({ + ...validMetadata(), + redirect_uris: Array.from( + { length: 11 }, + (_unused, index) => `https://client.example.com/cb/${index}`, + ), + }), + ).rejects.toThrow('invalid_redirect_uri'); + }); + + it('rejects a non-loopback http redirect_uri', async () => { + await expect( + clientsService.registerClient({ + ...validMetadata(), + redirect_uris: ['http://attacker.example.com/callback'], + }), + ).rejects.toThrow('invalid_redirect_uri'); + expect(clientRows).toHaveLength(0); + }); + + it('rejects the implicit grant type', async () => { + await expect( + clientsService.registerClient({ + ...validMetadata(), + grant_types: ['implicit'], + }), + ).rejects.toThrow('invalid_client_metadata'); + }); + + it('rejects a registration that asks for the token-exchange grant', async () => { + await expect( + clientsService.registerClient({ + ...validMetadata(), + grant_types: ['authorization_code', TOKEN_EXCHANGE_GRANT], + }), + ).rejects.toThrow('invalid_client_metadata'); + expect(clientRows).toHaveLength(0); + }); + + it('rejects client_secret_basic authentication for a registered client', async () => { + await expect( + clientsService.registerClient({ + ...validMetadata(), + token_endpoint_auth_method: 'client_secret_basic', + }), + ).rejects.toThrow('invalid_client_metadata'); + expect(clientRows).toHaveLength(0); + }); + }); + + describe('getClient / getClientOrThrow', () => { + it('returns null for an unknown client and the row for a known one', async () => { + const registered = await clientsService.registerClient(validMetadata()); + + expect(await clientsService.getClient('does-not-exist')).toBeNull(); + + const found = await clientsService.getClient(registered.client_id); + expect(found?.id).toBe(registered.client_id); + expect(found?.clientName).toBe('Test MCP Client'); + }); + + it('throws invalid_client when the client is unknown', async () => { + await expect( + clientsService.getClientOrThrow('does-not-exist'), + ).rejects.toThrow('unknown client'); + }); + + it('returns the client when it exists', async () => { + const registered = await clientsService.registerClient(validMetadata()); + + const client = await clientsService.getClientOrThrow( + registered.client_id, + ); + + expect(client.id).toBe(registered.client_id); + expect(client.redirectUris).toEqual([ + 'https://client.example.com/callback', + ]); + }); + }); + + describe('assertGrantTypeAllowed', () => { + const clientWith = (grantTypes: string[]): OAuthClient => + ({ + id: 'client-1', + created: '2026-01-01T00:00:00.000Z', + updated: '2026-01-01T00:00:00.000Z', + clientName: 'Test MCP Client', + redirectUris: ['https://client.example.com/callback'], + grantTypes, + tokenEndpointAuthMethod: 'none', + clientSecretHash: null, + } as OAuthClient); + + it('allows a grant type the client registered', () => { + expect(() => + clientsService.assertGrantTypeAllowed( + clientWith(['authorization_code', 'refresh_token']), + 'refresh_token', + ), + ).not.toThrow(); + }); + + it('rejects a grant type the client did not register', () => { + expect(() => + clientsService.assertGrantTypeAllowed( + clientWith(['authorization_code']), + 'refresh_token', + ), + ).toThrow('unauthorized_client'); + }); + + it('rejects the token-exchange grant for a public client', () => { + expect(() => + clientsService.assertGrantTypeAllowed( + clientWith(['authorization_code', 'refresh_token']), + TOKEN_EXCHANGE_GRANT, + ), + ).toThrow('unauthorized_client'); + }); + }); + + describe('ensureResourceServerClient', () => { + it('does nothing when no resource server secret is configured', async () => { + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(undefined); + + await clientsService.ensureResourceServerClient(); + + expect(clientRows).toHaveLength(0); + }); + + it('does nothing when the configured secret is an empty string', async () => { + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(''); + + await clientsService.ensureResourceServerClient(); + + expect(clientRows).toHaveLength(0); + }); + + it('fails fast when the configured secret is too short', async () => { + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue('a'.repeat(31)); + + // A configuration fault, reported as one — not as an OAuth protocol error. + await expect(clientsService.ensureResourceServerClient()).rejects.toThrow( + 'SYSTEM_PROP_INVALID', + ); + expect(clientRows).toHaveLength(0); + }); + + it('creates the resource server client with only the hashed secret', async () => { + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(RS_SECRET); + + await clientsService.ensureResourceServerClient(); + + expect(clientRows).toHaveLength(1); + const row = storedRow(RS_CLIENT_ID); + expect(row.clientName).toBe('OpenOps MCP Resource Server'); + expect(row.redirectUris).toEqual([]); + expect(row.grantTypes).toEqual([TOKEN_EXCHANGE_GRANT]); + expect(row.tokenEndpointAuthMethod).toBe('client_secret_basic'); + expect(row.clientSecretHash).toBe(sha256Hex(RS_SECRET)); + expect(JSON.stringify(row)).not.toContain(RS_SECRET); + }); + + it('keeps the row id within the 21-character id column limit', async () => { + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(RS_SECRET); + + await clientsService.ensureResourceServerClient(); + + expect(RS_CLIENT_ID.length).toBeLessThanOrEqual(21); + expect(String(storedRow(RS_CLIENT_ID).id).length).toBeLessThanOrEqual(21); + }); + + it('is idempotent across repeated boots', async () => { + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(RS_SECRET); + + await clientsService.ensureResourceServerClient(); + const created = storedRow(RS_CLIENT_ID).created; + await clientsService.ensureResourceServerClient(); + + expect(clientRows).toHaveLength(1); + expect(storedRow(RS_CLIENT_ID).created).toBe(created); + expect(storedRow(RS_CLIENT_ID).clientSecretHash).toBe( + sha256Hex(RS_SECRET), + ); + }); + + it('updates the stored hash when the configured secret is rotated', async () => { + const rotatedSecret = 'b'.repeat(48); + const secretSpy = jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(RS_SECRET); + + await clientsService.ensureResourceServerClient(); + secretSpy.mockReturnValue(rotatedSecret); + await clientsService.ensureResourceServerClient(); + + expect(clientRows).toHaveLength(1); + expect(storedRow(RS_CLIENT_ID).clientSecretHash).toBe( + sha256Hex(rotatedSecret), + ); + + await expect( + clientsService.authenticateResourceServerClient( + basicHeader(RS_CLIENT_ID, RS_SECRET), + ), + ).rejects.toThrow('invalid_client'); + const client = await clientsService.authenticateResourceServerClient( + basicHeader(RS_CLIENT_ID, rotatedSecret), + ); + expect(client.id).toBe(RS_CLIENT_ID); + }); + }); + + describe('authenticateResourceServerClient', () => { + beforeEach(async () => { + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(RS_SECRET); + await clientsService.ensureResourceServerClient(); + }); + + it('authenticates the resource server with correct Basic credentials', async () => { + const client = await clientsService.authenticateResourceServerClient( + basicHeader(RS_CLIENT_ID, RS_SECRET), + ); + + expect(client.id).toBe(RS_CLIENT_ID); + expect(client.grantTypes).toEqual([TOKEN_EXCHANGE_GRANT]); + expect(client.tokenEndpointAuthMethod).toBe('client_secret_basic'); + }); + + it('accepts a lowercase basic scheme', async () => { + const header = basicHeader(RS_CLIENT_ID, RS_SECRET).replace( + 'Basic ', + 'basic ', + ); + + const client = await clientsService.authenticateResourceServerClient( + header, + ); + + expect(client.id).toBe(RS_CLIENT_ID); + }); + + it('accepts a secret containing colons and percent-encoding', async () => { + const secret = 'aaaa:bbbb:cccc dddd/eeee-ffff-gggg-hhhh-iiii-jjjj'; + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(secret); + await clientsService.ensureResourceServerClient(); + + const header = `Basic ${Buffer.from( + `${RS_CLIENT_ID}:${encodeURIComponent(secret)}`, + ).toString('base64')}`; + + const client = await clientsService.authenticateResourceServerClient( + header, + ); + + expect(client.id).toBe(RS_CLIENT_ID); + }); + + it('tolerates a malformed percent escape in the secret', async () => { + const secret = `100%-literal-secret-${'z'.repeat(20)}`; + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(secret); + await clientsService.ensureResourceServerClient(); + + const client = await clientsService.authenticateResourceServerClient( + basicHeader(RS_CLIENT_ID, secret), + ); + + expect(client.id).toBe(RS_CLIENT_ID); + }); + + it('rejects a wrong secret', async () => { + await expect( + clientsService.authenticateResourceServerClient( + basicHeader(RS_CLIENT_ID, 'c'.repeat(48)), + ), + ).rejects.toThrow('invalid_client'); + }); + + it('rejects a missing Authorization header', async () => { + await expect( + clientsService.authenticateResourceServerClient(undefined), + ).rejects.toThrow('missing client credentials'); + }); + + it('rejects a non-Basic Authorization header', async () => { + await expect( + clientsService.authenticateResourceServerClient('Bearer some-token'), + ).rejects.toThrow('missing client credentials'); + }); + + it('rejects a public DCR client even when its id is known', async () => { + const registered = await clientsService.registerClient(validMetadata()); + + await expect( + clientsService.authenticateResourceServerClient( + basicHeader(registered.client_id, RS_SECRET), + ), + ).rejects.toThrow('invalid_client'); + }); + + it('does not reveal whether the client id or the secret was wrong', async () => { + const unknownClient = await clientsService + .authenticateResourceServerClient( + basicHeader('unknown-client', RS_SECRET), + ) + .catch((error: OAuthError) => error); + const wrongSecret = await clientsService + .authenticateResourceServerClient( + basicHeader(RS_CLIENT_ID, 'c'.repeat(48)), + ) + .catch((error: OAuthError) => error); + + expect(unknownClient).toBeInstanceOf(OAuthError); + expect(wrongSecret).toBeInstanceOf(OAuthError); + expect((unknownClient as OAuthError).errorCode).toBe('invalid_client'); + expect((unknownClient as OAuthError).statusCode).toBe(401); + expect((unknownClient as OAuthError).description).toBe( + (wrongSecret as OAuthError).description, + ); + expect((unknownClient as OAuthError).description).not.toContain( + RS_CLIENT_ID, + ); + expect((unknownClient as OAuthError).description).not.toMatch( + /unknown|not found|secret|password/i, + ); + }); + }); +}); diff --git a/packages/server/api/test/unit/oauth/grants.service.test.ts b/packages/server/api/test/unit/oauth/grants.service.test.ts new file mode 100644 index 0000000000..6e4dac34c7 --- /dev/null +++ b/packages/server/api/test/unit/oauth/grants.service.test.ts @@ -0,0 +1,370 @@ +type Row = Record; + +const grantRows: Row[] = []; +const refreshTokenRows: Row[] = []; + +function matches(row: Row, criteria: Row): boolean { + return Object.entries(criteria).every(([key, value]) => { + if (value instanceof Object && value.constructor.name === 'FindOperator') { + // The only operator used in this service is IsNull(). + return row[key] === null || row[key] === undefined; + } + return row[key] === value; + }); +} + +function makeRepo(store: Row[]) { + return () => ({ + find: async (options?: { where?: Row }) => + store.filter((row) => matches(row, options?.where ?? {})), + findOneBy: async (criteria: Row) => + store.find((row) => matches(row, criteria)) ?? null, + insert: async (row: Row) => { + // No uniqueness on (clientId, userId): repeat authorizations are separate + // connections, which is what the service under test relies on. + store.push(row); + }, + save: async (row: Row) => { + const index = store.findIndex((existing) => existing.id === row.id); + if (index >= 0) { + store[index] = { ...store[index], ...row }; + return store[index]; + } + store.push(row); + return row; + }, + update: async (criteria: Row, patch: Row) => { + const targets = store.filter((row) => matches(row, criteria)); + for (const target of targets) { + Object.assign(target, patch); + } + return { affected: targets.length }; + }, + }); +} + +jest.mock('../../../src/app/core/db/repo-factory', () => ({ + repoFactory: (entity: { options: { name: string } }) => + entity.options.name === 'oauth_grant' + ? makeRepo(grantRows) + : makeRepo(refreshTokenRows), +})); + +import { grantsService } from '../../../src/app/oauth/grants.service'; + +const BASE_PARAMS = { + clientId: 'client-1', + userId: 'user-1', + resourceId: 'mcp', +}; + +function seedRefreshToken(overrides: Row = {}): Row { + const row: Row = { + id: `refresh-${refreshTokenRows.length + 1}`, + tokenHash: `hash-${refreshTokenRows.length + 1}`, + grantId: 'grant-1', + familyId: 'family-1', + clientId: 'client-1', + resource: 'https://ops.example.com/mcp', + scope: 'mcp', + expiresAt: new Date(Date.now() + 86_400_000).toISOString(), + revokedAt: null, + ...overrides, + }; + refreshTokenRows.push(row); + return row; +} + +describe('grantsService', () => { + beforeEach(() => { + grantRows.length = 0; + refreshTokenRows.length = 0; + grantsService.clearSnapshotCacheForTests(); + }); + + describe('create', () => { + it('creates an active grant on the default project', async () => { + const grant = await grantsService.create(BASE_PARAMS); + + expect(grantRows).toHaveLength(1); + expect(grant).toMatchObject({ + clientId: 'client-1', + userId: 'user-1', + resourceId: 'mcp', + status: 'active', + revokedAt: null, + }); + // resourceId alone says what the connection is for; a scope column would + // restate it, since each resource grants exactly one. + expect('scope' in (grantRows[0] as object)).toBe(false); + }); + + it('creates an independent grant each time the same client is authorized', async () => { + const first = await grantsService.create(BASE_PARAMS); + const second = await grantsService.create(BASE_PARAMS); + + expect(second.id).not.toBe(first.id); + expect(grantRows).toHaveLength(2); + expect(grantRows.every((row) => row.status === 'active')).toBe(true); + }); + + it('revoking one connection leaves the user other connections intact', async () => { + const first = await grantsService.create(BASE_PARAMS); + const second = await grantsService.create(BASE_PARAMS); + const firstToken = seedRefreshToken({ grantId: first.id }); + const secondToken = seedRefreshToken({ grantId: second.id }); + + await grantsService.revoke(first.id); + + expect(await grantsService.getGrantSnapshot(first.id)).toMatchObject({ + status: 'revoked', + }); + expect(await grantsService.getGrantSnapshot(second.id)).toMatchObject({ + status: 'active', + }); + expect(firstToken.revokedAt).toEqual(expect.any(String)); + expect(secondToken.revokedAt).toBeNull(); + }); + + it('records no project on the grant', async () => { + const grant = await grantsService.create(BASE_PARAMS); + + // Which project a connection acts in changes over its life, so it belongs to the + // credential chain (the refresh token), not here. A copy on the grant could only + // be where the connection started, and using it as the refresh default silently + // undid switches. + expect('projectId' in (grant as object)).toBe(false); + expect( + 'setActiveProject' in (grantsService as Record), + ).toBe(false); + }); + + it('creates separate grants per client and per user', async () => { + await grantsService.create(BASE_PARAMS); + await grantsService.create({ + ...BASE_PARAMS, + clientId: 'client-2', + }); + await grantsService.create({ + ...BASE_PARAMS, + userId: 'user-2', + }); + + expect(grantRows).toHaveLength(3); + }); + }); + + describe('revoke', () => { + it('marks the grant revoked and cascades to its unrevoked refresh tokens', async () => { + const grant = await grantsService.create(BASE_PARAMS); + const tokenA = seedRefreshToken({ grantId: grant.id }); + const tokenB = seedRefreshToken({ grantId: grant.id }); + const otherGrantToken = seedRefreshToken({ grantId: 'other-grant' }); + + await grantsService.revoke(grant.id); + + expect(grantRows[0]).toMatchObject({ status: 'revoked' }); + expect(grantRows[0].revokedAt).toEqual(expect.any(String)); + expect(tokenA.revokedAt).toEqual(expect.any(String)); + expect(tokenB.revokedAt).toEqual(expect.any(String)); + expect(otherGrantToken.revokedAt).toBeNull(); + }); + + it('leaves an already-revoked token timestamp untouched', async () => { + const grant = await grantsService.create(BASE_PARAMS); + const earlier = '2020-01-01T00:00:00.000Z'; + const alreadyRevoked = seedRefreshToken({ + grantId: grant.id, + revokedAt: earlier, + }); + + await grantsService.revoke(grant.id); + + expect(alreadyRevoked.revokedAt).toBe(earlier); + }); + + it('busts the snapshot cache so revocation takes effect immediately', async () => { + const grant = await grantsService.create(BASE_PARAMS); + await grantsService.getGrantSnapshot(grant.id); + + await grantsService.revoke(grant.id); + + expect(await grantsService.getGrantSnapshot(grant.id)).toMatchObject({ + status: 'revoked', + }); + await expect( + grantsService.getActiveGrantOrThrow(grant.id), + ).rejects.toThrow('revoked'); + }); + }); + + describe('revokeForUser', () => { + it('revokes a grant the user owns', async () => { + const grant = await grantsService.create(BASE_PARAMS); + + await grantsService.revokeForUser(grant.id, 'user-1'); + + expect(grantRows[0]).toMatchObject({ status: 'revoked' }); + }); + + it("refuses to revoke another user's grant", async () => { + const grant = await grantsService.create(BASE_PARAMS); + + await expect( + grantsService.revokeForUser(grant.id, 'attacker'), + ).rejects.toThrow('unknown grant'); + expect(grantRows[0]).toMatchObject({ status: 'active' }); + }); + }); + + describe('getGrantSnapshot', () => { + it('returns undefined for an unknown grant', async () => { + expect(await grantsService.getGrantSnapshot('missing')).toBeUndefined(); + }); + + it('serves repeated reads from cache without hitting the store again', async () => { + const grant = await grantsService.create(BASE_PARAMS); + await grantsService.getGrantSnapshot(grant.id); + + // Mutate the row behind the service's back; the cached read must not see it. + grantRows[0].status = 'revoked'; + + expect(await grantsService.getGrantSnapshot(grant.id)).toMatchObject({ + status: 'active', + }); + + grantsService.clearSnapshotCacheForTests(); + + expect(await grantsService.getGrantSnapshot(grant.id)).toMatchObject({ + status: 'revoked', + }); + }); + + it('drops expired entries instead of growing forever', async () => { + // Each reconnect creates a new grant, so the cache is keyed by an ever-growing set. + // Nothing evicted an entry once its window passed: a stale one was overwritten on + // the next read, leaving the key behind for the life of the process. + const grant = await grantsService.create(BASE_PARAMS); + await grantsService.getGrantSnapshot(grant.id); + + // Fill past the sweep threshold with ids that will never be read again, as a fleet + // of short-lived connections would. + for (let i = 0; i < 10_000; i++) { + await grantsService.getGrantSnapshot(`departed-grant-${i}`); + } + + // Every entry above is now stale, so the next insert sweeps them. + const nowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(Date.now() + 61_000); + await grantsService.getGrantSnapshot('one-more'); + nowSpy.mockRestore(); + + expect(grantsService.snapshotCacheSizeForTests()).toBeLessThan(10_000); + }); + + it('bounds the cache even when every entry is still live', async () => { + // A sweep can free nothing if the working set really is that large. The cache is an + // optimization, so memory is bounded ahead of the query count. + for (let i = 0; i < 10_001; i++) { + await grantsService.getGrantSnapshot(`live-grant-${i}`); + } + + expect(grantsService.snapshotCacheSizeForTests()).toBeLessThanOrEqual( + 10_000, + ); + }); + + it('re-reads once the cache entry expires', async () => { + const grant = await grantsService.create(BASE_PARAMS); + await grantsService.getGrantSnapshot(grant.id); + grantRows[0].status = 'revoked'; + + const nowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(Date.now() + 61_000); + + expect(await grantsService.getGrantSnapshot(grant.id)).toMatchObject({ + status: 'revoked', + }); + + nowSpy.mockRestore(); + }); + }); + + describe('getActiveGrantOrThrow', () => { + it('returns the snapshot for an active grant', async () => { + const grant = await grantsService.create(BASE_PARAMS); + + expect(await grantsService.getActiveGrantOrThrow(grant.id)).toMatchObject( + { + id: grant.id, + userId: 'user-1', + status: 'active', + }, + ); + }); + + it('throws for an unknown grant', async () => { + await expect( + grantsService.getActiveGrantOrThrow('missing'), + ).rejects.toThrow('revoked'); + }); + }); + + describe('listForUser', () => { + it('lists only the active grants belonging to the user', async () => { + const mine = await grantsService.create(BASE_PARAMS); + await grantsService.create({ + ...BASE_PARAMS, + userId: 'user-2', + clientId: 'client-2', + }); + const revoked = await grantsService.create({ + ...BASE_PARAMS, + clientId: 'client-3', + }); + await grantsService.revoke(revoked.id); + + const grants = await grantsService.listForUser('user-1'); + + expect(grants.map((grant) => grant.id)).toEqual([mine.id]); + }); + }); + + describe('touch', () => { + it('records last usage on the first call', async () => { + const grant = await grantsService.create(BASE_PARAMS); + + await grantsService.touch(grant.id); + + expect(grantRows[0].lastUsedAt).toEqual(expect.any(String)); + }); + + it('throttles repeated writes within the interval', async () => { + const grant = await grantsService.create(BASE_PARAMS); + await grantsService.touch(grant.id); + const firstWrite = grantRows[0].lastUsedAt; + + grantRows[0].lastUsedAt = 'sentinel'; + await grantsService.touch(grant.id); + + expect(grantRows[0].lastUsedAt).toBe('sentinel'); + expect(firstWrite).toEqual(expect.any(String)); + }); + + it('writes again once the interval has passed', async () => { + const grant = await grantsService.create(BASE_PARAMS); + await grantsService.touch(grant.id); + grantRows[0].lastUsedAt = 'sentinel'; + + const nowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(Date.now() + 61_000); + await grantsService.touch(grant.id); + nowSpy.mockRestore(); + + expect(grantRows[0].lastUsedAt).not.toBe('sentinel'); + }); + }); +}); diff --git a/packages/server/api/test/unit/oauth/oauth-cleanup-job.test.ts b/packages/server/api/test/unit/oauth/oauth-cleanup-job.test.ts new file mode 100644 index 0000000000..df190ec914 --- /dev/null +++ b/packages/server/api/test/unit/oauth/oauth-cleanup-job.test.ts @@ -0,0 +1,290 @@ +import { LessThan } from 'typeorm'; + +type Row = Record; + +const codeRows: Row[] = []; +const pendingRows: Row[] = []; +const refreshRows: Row[] = []; + +type QueryBuilderStub = { + delete: jest.Mock; + where: jest.Mock; + andWhere: jest.Mock; + execute: jest.Mock; +}; + +const clientQueryBuilder: QueryBuilderStub = { + delete: jest.fn(), + where: jest.fn(), + andWhere: jest.fn(), + execute: jest.fn(), +}; + +const clientRepo = { + createQueryBuilder: jest.fn(() => clientQueryBuilder), +}; + +const grantQueryBuilder: QueryBuilderStub = { + delete: jest.fn(), + where: jest.fn(), + andWhere: jest.fn(), + execute: jest.fn(), +}; + +const grantRepo = { + createQueryBuilder: jest.fn(() => grantQueryBuilder), +}; + +function isFindOperator(value: unknown): value is { value: unknown } { + return ( + typeof value === 'object' && + value !== null && + value.constructor.name === 'FindOperator' + ); +} + +/** + * Only `LessThan` is used by the cleanup job, so that is all this honours. + * Compared as instants rather than strings, because the service binds cutoffs as + * `Date` objects — see `oauth-query.ts` for why. + */ +function matches(row: Row, criteria: Row): boolean { + return Object.entries(criteria).every(([key, expected]) => { + if (isFindOperator(expected)) { + const actual = row[key]; + if (typeof actual !== 'string') { + return false; + } + const cutoff = expected.value; + if (!(cutoff instanceof Date)) { + throw new Error(`expected a Date cutoff for ${key}`); + } + return new Date(actual).getTime() < cutoff.getTime(); + } + return row[key] === expected; + }); +} + +function makeRepo(store: Row[]) { + return () => ({ + delete: async (criteria: Row) => { + const survivors = store.filter((row) => !matches(row, criteria)); + const affected = store.length - survivors.length; + store.length = 0; + store.push(...survivors); + return { affected }; + }, + }); +} + +jest.mock('../../../src/app/core/db/repo-factory', () => ({ + repoFactory: (entity: { options: { name: string } }) => { + switch (entity.options.name) { + case 'oauth_authorization_code': + return makeRepo(codeRows); + case 'oauth_pending_authorization': + return makeRepo(pendingRows); + case 'oauth_refresh_token': + return makeRepo(refreshRows); + case 'oauth_client': + return () => clientRepo; + case 'oauth_grant': + return () => grantRepo; + default: + throw new Error(`unexpected entity ${entity.options.name}`); + } + }, +})); + +const loggerInfo = jest.fn(); + +jest.mock('@openops/server-shared', () => ({ + ...jest.requireActual('@openops/server-shared'), + logger: { info: loggerInfo, warn: jest.fn(), error: jest.fn() }, +})); + +import { + OAUTH_CLEANUP_CRON, + oauthCleanupJobHandler, +} from '../../../src/app/oauth/oauth-cleanup-job'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function isoDaysAgo(days: number): string { + return new Date(Date.now() - days * DAY_MS).toISOString(); +} + +function isoInMinutes(minutes: number): string { + return new Date(Date.now() + minutes * 60 * 1000).toISOString(); +} + +describe('oauthCleanupJobHandler', () => { + beforeEach(() => { + codeRows.length = 0; + pendingRows.length = 0; + refreshRows.length = 0; + jest.clearAllMocks(); + clientQueryBuilder.delete.mockReturnValue(clientQueryBuilder); + clientQueryBuilder.where.mockReturnValue(clientQueryBuilder); + clientQueryBuilder.andWhere.mockReturnValue(clientQueryBuilder); + clientQueryBuilder.execute.mockResolvedValue({ affected: 2 }); + grantQueryBuilder.delete.mockReturnValue(grantQueryBuilder); + grantQueryBuilder.where.mockReturnValue(grantQueryBuilder); + grantQueryBuilder.andWhere.mockReturnValue(grantQueryBuilder); + grantQueryBuilder.execute.mockResolvedValue({ affected: 1 }); + }); + + it('deletes only dead connections: no live refresh token and unused for long enough', async () => { + await oauthCleanupJobHandler(); + + expect(grantQueryBuilder.execute).toHaveBeenCalled(); + const [dateClause, dateParams] = grantQueryBuilder.where.mock.calls[0]; + expect(dateClause).toContain('COALESCE("lastUsedAt", "created")'); + expect(dateParams.cutoff).toBeInstanceOf(Date); + expect((dateParams.cutoff as Date).getTime()).toBeLessThan(Date.now()); + // A connection with any unrevoked refresh token is still live and must survive. + expect(grantQueryBuilder.andWhere.mock.calls[0][0]).toContain( + 'NOT EXISTS (SELECT 1 FROM oauth_refresh_token t WHERE t."grantId" = oauth_grant.id AND t."revokedAt" IS NULL)', + ); + }); + + it('reports how many dead connections it removed', async () => { + await oauthCleanupJobHandler(); + + expect(loggerInfo).toHaveBeenCalledWith( + 'OAuth cleanup completed', + expect.objectContaining({ deadGrants: 1 }), + ); + }); + + it('runs hourly', () => { + expect(OAUTH_CLEANUP_CRON).toBe('0 * * * *'); + }); + + it('deletes expired authorization codes and keeps live ones', async () => { + codeRows.push( + { id: 'expired-code', expiresAt: isoDaysAgo(1) }, + { id: 'live-code', expiresAt: isoInMinutes(1) }, + ); + + await oauthCleanupJobHandler(); + + expect(codeRows.map((row) => row.id)).toEqual(['live-code']); + }); + + it('deletes expired pending authorizations and keeps live ones', async () => { + pendingRows.push( + { id: 'expired-pending', expiresAt: isoDaysAgo(1) }, + { id: 'live-pending', expiresAt: isoInMinutes(10) }, + ); + + await oauthCleanupJobHandler(); + + expect(pendingRows.map((row) => row.id)).toEqual(['live-pending']); + }); + + it('deletes refresh tokens that can no longer be rotated', async () => { + refreshRows.push( + { id: 'expired-token', expiresAt: isoDaysAgo(1), revokedAt: null }, + { id: 'live-token', expiresAt: isoDaysAgo(-30), revokedAt: null }, + ); + + await oauthCleanupJobHandler(); + + expect(refreshRows.map((row) => row.id)).toEqual(['live-token']); + }); + + it('keeps revoked refresh tokens until they expire, however long ago they were rotated', async () => { + refreshRows.push( + { + id: 'revoked-long-ago-still-valid', + expiresAt: isoDaysAgo(-20), + revokedAt: isoDaysAgo(25), + }, + { + id: 'revoked-recently', + expiresAt: isoDaysAgo(-20), + revokedAt: isoDaysAgo(1), + }, + { + id: 'revoked-and-expired', + expiresAt: isoDaysAgo(1), + revokedAt: isoDaysAgo(25), + }, + { + id: 'never-revoked', + expiresAt: isoDaysAgo(-20), + revokedAt: null, + }, + ); + + await oauthCleanupJobHandler(); + + // Age of the rotation is irrelevant: a row survives while the token it represents + // could still be presented, which is exactly the window in which a replay has to be + // recognised as reuse rather than reported as an unknown token. + expect(refreshRows.map((row) => row.id)).toEqual([ + 'revoked-long-ago-still-valid', + 'revoked-recently', + 'never-revoked', + ]); + }); + + it('deletes old public clients that no grant references, via a NOT EXISTS subquery', async () => { + await oauthCleanupJobHandler(); + + expect(clientQueryBuilder.execute).toHaveBeenCalledTimes(1); + + const whereClauses = [ + ...clientQueryBuilder.where.mock.calls, + ...clientQueryBuilder.andWhere.mock.calls, + ]; + const clauseSql = whereClauses.map((call) => call[0] as string).join(' | '); + + expect(clauseSql).toContain('"created" <'); + expect(clauseSql).toContain('"tokenEndpointAuthMethod" ='); + expect(clauseSql).toContain('NOT EXISTS'); + expect(clauseSql).toContain('oauth_grant'); + + const parameters = Object.assign( + {}, + ...whereClauses.map((call) => call[1] ?? {}), + ) as Record; + + expect(parameters.authMethod).toBe('none'); + // Bound as a Date, so the driver serialises it the way it serialises stored + // timestamps rather than leaving a textual comparison to chance. + expect(parameters.cutoff).toBeInstanceOf(Date); + const cutoffAge = Date.now() - (parameters.cutoff as Date).getTime(); + expect(cutoffAge).toBeGreaterThan(29 * DAY_MS); + expect(cutoffAge).toBeLessThan(31 * DAY_MS); + }); + + it('logs a single summary with the deleted counts', async () => { + codeRows.push({ id: 'expired-code', expiresAt: isoDaysAgo(1) }); + pendingRows.push({ id: 'expired-pending', expiresAt: isoDaysAgo(1) }); + refreshRows.push( + { id: 'expired-token', expiresAt: isoDaysAgo(1), revokedAt: null }, + { + id: 'revoked-long-ago', + expiresAt: isoDaysAgo(-30), + revokedAt: isoDaysAgo(8), + }, + ); + + await oauthCleanupJobHandler(); + + expect(loggerInfo).toHaveBeenCalledTimes(1); + expect(loggerInfo.mock.calls[0][1]).toEqual({ + authorizationCodes: 1, + pendingAuthorizations: 1, + expiredRefreshTokens: 1, + unusedClients: 2, + deadGrants: 1, + }); + }); + + it('exposes value on a LessThan find operator, which the store mock relies on', () => { + expect(LessThan('2020-01-01').value).toBe('2020-01-01'); + }); +}); diff --git a/packages/server/api/test/unit/oauth/oauth-cleanup-registration.test.ts b/packages/server/api/test/unit/oauth/oauth-cleanup-registration.test.ts new file mode 100644 index 0000000000..7f434b2b4b --- /dev/null +++ b/packages/server/api/test/unit/oauth/oauth-cleanup-registration.test.ts @@ -0,0 +1,98 @@ +const registerJobHandler = jest.fn(); +const upsertJob = jest.fn(); +const repoDelete = jest.fn(async () => ({ affected: 0 })); + +jest.mock('../../../src/app/helper/system-jobs/job-handlers', () => ({ + systemJobHandlers: { registerJobHandler }, +})); + +jest.mock('../../../src/app/helper/system-jobs', () => ({ + systemJobsSchedule: { upsertJob }, +})); + +// Any database access at all is the signal these tests watch for. +jest.mock('../../../src/app/core/db/repo-factory', () => ({ + repoFactory: () => () => ({ + delete: repoDelete, + createQueryBuilder: () => ({ + delete: () => ({ + where: () => ({ + andWhere: () => ({ + andWhere: () => ({ execute: async () => ({ affected: 0 }) }), + execute: async () => ({ affected: 0 }), + }), + }), + }), + }), + }), +})); + +import { SystemJobName } from '../../../src/app/helper/system-jobs/common'; +import { + registerOAuthCleanupHandler, + scheduleOAuthCleanupJob, +} from '../../../src/app/oauth/oauth-cleanup-job'; +import { oauthConfig } from '../../../src/app/oauth/oauth-config'; + +/** + * The schedule is stored in Redis, so it outlives the boot that created it. These cover + * the next boot — which may have OAuth switched off. + */ +describe('OAuth cleanup registration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('registers the handler even when OAuth is disabled', () => { + jest.spyOn(oauthConfig, 'isEnabled').mockReturnValue(false); + + registerOAuthCleanupHandler(); + + // Without this, the worker cannot find a handler for a job still on the schedule + // and fails it — hourly, for a feature nobody is using. + expect(registerJobHandler).toHaveBeenCalledWith( + SystemJobName.OAUTH_CLEANUP, + expect.any(Function), + ); + }); + + it('touches nothing when the job fires while OAuth is disabled', async () => { + jest.spyOn(oauthConfig, 'isEnabled').mockReturnValue(false); + + registerOAuthCleanupHandler(); + const handler = registerJobHandler.mock.calls[0][1]; + + await expect(handler({})).resolves.toBeUndefined(); + expect(repoDelete).not.toHaveBeenCalled(); + }); + + it('does the work when the job fires while OAuth is enabled', async () => { + jest.spyOn(oauthConfig, 'isEnabled').mockReturnValue(true); + + registerOAuthCleanupHandler(); + const handler = registerJobHandler.mock.calls[0][1]; + + await handler({}); + + // Proves the guard above is the reason nothing happened, not a broken handler. + expect(repoDelete).toHaveBeenCalled(); + }); + + it('schedules the repeatable job separately from registering the handler', async () => { + await scheduleOAuthCleanupJob(); + + expect(upsertJob).toHaveBeenCalledWith( + expect.objectContaining({ + job: expect.objectContaining({ name: SystemJobName.OAUTH_CLEANUP }), + schedule: expect.objectContaining({ type: 'repeated' }), + }), + ); + // Scheduling happens only on an OAuth-enabled boot, so it must not be what puts the + // handler in place. + expect(registerJobHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/api/test/unit/oauth/oauth-config-validation.test.ts b/packages/server/api/test/unit/oauth/oauth-config-validation.test.ts new file mode 100644 index 0000000000..a4863fd807 --- /dev/null +++ b/packages/server/api/test/unit/oauth/oauth-config-validation.test.ts @@ -0,0 +1,136 @@ +import { AppSystemProp, system } from '@openops/server-shared'; +import { oauthConfig } from '../../../src/app/oauth/oauth-config'; +import { validateOAuthConfiguration } from '../../../src/app/oauth/oauth-config-validation'; + +const ISSUER = 'https://ops.example.com/api'; +const MCP_URI = 'https://ops.example.com/mcp'; + +describe('validateOAuthConfiguration', () => { + beforeEach(() => { + jest.spyOn(system, 'get').mockReturnValue(undefined); + jest.spyOn(oauthConfig, 'getIssuerUrl').mockReturnValue(ISSUER); + jest.spyOn(oauthConfig, 'getApiAudience').mockReturnValue(ISSUER); + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(MCP_URI); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('accepts a well-formed configuration', () => { + expect(() => validateOAuthConfiguration()).not.toThrow(); + }); + + it('accepts loopback URLs over plain http, for local development', () => { + jest + .spyOn(oauthConfig, 'getIssuerUrl') + .mockReturnValue('http://localhost:3000'); + jest + .spyOn(oauthConfig, 'getApiAudience') + .mockReturnValue('http://localhost:3000'); + jest + .spyOn(oauthConfig, 'getMcpResourceUrl') + .mockReturnValue('http://localhost:3020/mcp'); + + expect(() => validateOAuthConfiguration()).not.toThrow(); + }); + + it('accepts a deployment with no mcp resource', () => { + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(undefined); + + expect(() => validateOAuthConfiguration()).not.toThrow(); + }); + + it('refuses an mcp resource that collapses into the api audience', () => { + // Were these equal, the resource server would accept API-audience tokens and + // the no-token-passthrough guarantee would silently stop holding. + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(ISSUER); + + expect(() => validateOAuthConfiguration()).toThrow('must differ'); + }); + + it.each([ + ['a trailing slash', `${ISSUER}/`], + ['a different case in the host', 'https://OPS.example.com/api'], + ])('refuses an mcp resource that differs only by %s', (_label, mcpUrl) => { + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(mcpUrl); + + expect(() => validateOAuthConfiguration()).toThrow('must differ'); + }); + + it.each([ + ['a relative value', '/api'], + ['a non-URL', 'not a url'], + ['plain http on a public host', 'http://ops.example.com/api'], + ['a query string', 'https://ops.example.com/api?x=1'], + ['a fragment', 'https://ops.example.com/api#f'], + ])('refuses an issuer that is %s', (_label, issuer) => { + jest.spyOn(oauthConfig, 'getIssuerUrl').mockReturnValue(issuer); + jest.spyOn(oauthConfig, 'getApiAudience').mockReturnValue(issuer); + + expect(() => validateOAuthConfiguration()).toThrow('OPS_OAUTH_ISSUER_URL'); + }); + + it('refuses a malformed mcp resource url', () => { + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue('not a url'); + + expect(() => validateOAuthConfiguration()).toThrow('OPS_MCP_RESOURCE_URL'); + }); + + it('accepts the TTLs this repository ships as defaults', () => { + // Guards the bounds themselves: a range that excluded the shipped configuration + // would fail every boot, and the assertions below would still look correct. + expect(oauthConfig.getAccessTokenTtlSeconds()).toBe(900); + expect(oauthConfig.getExchangeTokenTtlSeconds()).toBe(300); + expect(oauthConfig.getRefreshTokenTtlDays()).toBe(30); + expect(() => validateOAuthConfiguration()).not.toThrow(); + }); + + it.each([ + [ + 'getAccessTokenTtlSeconds', + 30, + AppSystemProp.OAUTH_ACCESS_TOKEN_TTL_SECONDS, + ], + [ + 'getAccessTokenTtlSeconds', + 60 * 60 * 24 * 30, + AppSystemProp.OAUTH_ACCESS_TOKEN_TTL_SECONDS, + ], + [ + 'getExchangeTokenTtlSeconds', + 30, + AppSystemProp.OAUTH_EXCHANGE_TOKEN_TTL_SECONDS, + ], + [ + 'getExchangeTokenTtlSeconds', + 3600, + AppSystemProp.OAUTH_EXCHANGE_TOKEN_TTL_SECONDS, + ], + ['getRefreshTokenTtlDays', 0, AppSystemProp.OAUTH_REFRESH_TOKEN_TTL_DAYS], + ['getRefreshTokenTtlDays', 365, AppSystemProp.OAUTH_REFRESH_TOKEN_TTL_DAYS], + ] as const)( + 'refuses %s of %d, naming the property at fault', + (getter, value, prop) => { + jest.spyOn(oauthConfig, getter).mockReturnValue(value); + + // A wrong TTL boots a server that looks healthy while a guarantee is gone, so it + // has to fail here rather than surface as a revocation that takes a month. + expect(() => validateOAuthConfiguration()).toThrow(`OPS_${prop}`); + }, + ); + + it('refuses a fractional TTL rather than silently truncating it', () => { + jest.spyOn(oauthConfig, 'getAccessTokenTtlSeconds').mockReturnValue(900.5); + + expect(() => validateOAuthConfiguration()).toThrow('whole number'); + }); + + it('refuses to run on sqlite, where the migration is not registered', () => { + (system.get as jest.Mock).mockImplementation((prop: string) => + prop === AppSystemProp.DB_TYPE ? 'SQLITE3' : undefined, + ); + + expect(() => validateOAuthConfiguration()).toThrow('PostgreSQL'); + }); +}); diff --git a/packages/server/api/test/unit/oauth/oauth-config.test.ts b/packages/server/api/test/unit/oauth/oauth-config.test.ts new file mode 100644 index 0000000000..6ae4220a9b --- /dev/null +++ b/packages/server/api/test/unit/oauth/oauth-config.test.ts @@ -0,0 +1,71 @@ +import { AppSystemProp, system } from '@openops/server-shared'; +import { oauthConfig } from '../../../src/app/oauth/oauth-config'; + +describe('oauthConfig', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('strips trailing slashes from the issuer', () => { + jest + .spyOn(system, 'getOrThrow') + .mockReturnValue('https://ops.example.com/api/'); + + expect(oauthConfig.getIssuerUrl()).toBe('https://ops.example.com/api'); + }); + + it('uses the issuer as the api audience', () => { + jest + .spyOn(system, 'getOrThrow') + .mockReturnValue('https://ops.example.com/api'); + + expect(oauthConfig.getApiAudience()).toBe('https://ops.example.com/api'); + }); + + it('normalizes the mcp resource url and returns undefined when unset', () => { + const getSpy = jest.spyOn(system, 'get'); + + getSpy.mockReturnValue('https://ops.example.com/mcp/'); + expect(oauthConfig.getMcpResourceUrl()).toBe('https://ops.example.com/mcp'); + + getSpy.mockReturnValue(undefined); + expect(oauthConfig.getMcpResourceUrl()).toBeUndefined(); + }); + + it('reads each TTL from its own setting', () => { + const getNumber = jest + .spyOn(system, 'getNumberOrThrow') + .mockReturnValue(42); + + expect(oauthConfig.getAccessTokenTtlSeconds()).toBe(42); + expect(getNumber).toHaveBeenLastCalledWith( + AppSystemProp.OAUTH_ACCESS_TOKEN_TTL_SECONDS, + ); + + expect(oauthConfig.getRefreshTokenTtlDays()).toBe(42); + expect(getNumber).toHaveBeenLastCalledWith( + AppSystemProp.OAUTH_REFRESH_TOKEN_TTL_DAYS, + ); + + expect(oauthConfig.getExchangeTokenTtlSeconds()).toBe(42); + expect(getNumber).toHaveBeenLastCalledWith( + AppSystemProp.OAUTH_EXCHANGE_TOKEN_TTL_SECONDS, + ); + }); + + it('is disabled unless explicitly enabled', () => { + // Driven through the mock rather than the ambient environment. A developer's local + // .env sets this, and the default when nothing sets it is what is under test. + const getBoolean = jest.spyOn(system, 'getBoolean'); + + getBoolean.mockReturnValue(undefined); + expect(oauthConfig.isEnabled()).toBe(false); + + getBoolean.mockReturnValue(false); + expect(oauthConfig.isEnabled()).toBe(false); + + getBoolean.mockReturnValue(true); + expect(oauthConfig.isEnabled()).toBe(true); + expect(getBoolean).toHaveBeenLastCalledWith(AppSystemProp.OAUTH_ENABLED); + }); +}); diff --git a/packages/server/api/test/unit/oauth/oauth-crypto.test.ts b/packages/server/api/test/unit/oauth/oauth-crypto.test.ts new file mode 100644 index 0000000000..15ddf28dcc --- /dev/null +++ b/packages/server/api/test/unit/oauth/oauth-crypto.test.ts @@ -0,0 +1,36 @@ +import { + generateOpaqueToken, + sha256Hex, + timingSafeStringEqual, +} from '../../../src/app/oauth/oauth-crypto'; + +describe('oauth-crypto', () => { + it('generates unique 43-char base64url tokens (32 bytes of entropy)', () => { + const tokens = new Set( + Array.from({ length: 50 }, () => generateOpaqueToken()), + ); + + expect(tokens.size).toBe(50); + for (const token of tokens) { + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + } + }); + + it('hashes with SHA-256 to stable lowercase hex', () => { + expect(sha256Hex('abc')).toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + ); + expect(sha256Hex('abc')).toBe(sha256Hex('abc')); + expect(sha256Hex('abd')).not.toBe(sha256Hex('abc')); + }); + + it('compares equal strings safely', () => { + expect(timingSafeStringEqual('same-secret', 'same-secret')).toBe(true); + }); + + it('returns false for unequal strings without throwing on length mismatch', () => { + expect(timingSafeStringEqual('a', 'ab')).toBe(false); + expect(timingSafeStringEqual('', 'nonempty')).toBe(false); + expect(timingSafeStringEqual('secret', 'Secret')).toBe(false); + }); +}); diff --git a/packages/server/api/test/unit/oauth/oauth-errors.test.ts b/packages/server/api/test/unit/oauth/oauth-errors.test.ts new file mode 100644 index 0000000000..a389952001 --- /dev/null +++ b/packages/server/api/test/unit/oauth/oauth-errors.test.ts @@ -0,0 +1,40 @@ +import { + invalidClient, + invalidGrant, + invalidRequest, + invalidTarget, + OAuthError, + serverError, +} from '../../../src/app/oauth/oauth-errors'; + +describe('OAuthError', () => { + it('carries RFC 6749 fields and a 400 status by default', () => { + const error = invalidGrant('code expired'); + + expect(error).toBeInstanceOf(OAuthError); + expect(error.toBody()).toEqual({ + error: 'invalid_grant', + error_description: 'code expired', + }); + expect(error.statusCode).toBe(400); + }); + + it('uses 401 for invalid_client', () => { + expect(invalidClient('bad credentials').statusCode).toBe(401); + }); + + it('uses 500 for server_error', () => { + expect(serverError('signing key missing').statusCode).toBe(500); + }); + + it('uses 400 for invalid_request and invalid_target', () => { + expect(invalidRequest('missing code').statusCode).toBe(400); + expect(invalidTarget('unknown resource').statusCode).toBe(400); + }); + + it('is throwable and catchable as an Error', () => { + expect(() => { + throw invalidRequest('boom'); + }).toThrow('invalid_request: boom'); + }); +}); diff --git a/packages/server/api/test/unit/oauth/oauth-metadata.test.ts b/packages/server/api/test/unit/oauth/oauth-metadata.test.ts new file mode 100644 index 0000000000..0eb4c31d58 --- /dev/null +++ b/packages/server/api/test/unit/oauth/oauth-metadata.test.ts @@ -0,0 +1,103 @@ +import { oauthConfig } from '../../../src/app/oauth/oauth-config'; +import { + buildAuthorizationServerMetadata, + getWellKnownPathVariants, +} from '../../../src/app/oauth/oauth-metadata'; + +const ISSUER = 'https://ops.example.com/api'; +const MCP_URI = 'https://ops.example.com/mcp'; + +describe('buildAuthorizationServerMetadata', () => { + beforeEach(() => { + jest.spyOn(oauthConfig, 'getIssuerUrl').mockReturnValue(ISSUER); + jest.spyOn(oauthConfig, 'getApiAudience').mockReturnValue(ISSUER); + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(MCP_URI); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('advertises exactly the endpoints and capabilities that exist', () => { + expect(buildAuthorizationServerMetadata()).toEqual({ + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/v1/oauth/authorize`, + token_endpoint: `${ISSUER}/v1/oauth/token`, + registration_endpoint: `${ISSUER}/v1/oauth/register`, + revocation_endpoint: `${ISSUER}/v1/oauth/revoke`, + jwks_uri: `${ISSUER}/v1/oauth/jwks.json`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none', 'client_secret_basic'], + scopes_supported: ['api', 'mcp'], + authorization_response_iss_parameter_supported: true, + }); + }); + + it('claims no OpenID Connect capability, because none is implemented', () => { + const document = buildAuthorizationServerMetadata() as Record< + string, + unknown + >; + + for (const oidcOnlyField of [ + 'id_token_signing_alg_values_supported', + 'subject_types_supported', + 'userinfo_endpoint', + 'claims_supported', + ]) { + expect(document[oidcOnlyField]).toBeUndefined(); + } + }); + + it('offers no implicit or password grant', () => { + const { grant_types_supported, response_types_supported } = + buildAuthorizationServerMetadata(); + + expect(grant_types_supported).not.toContain('implicit'); + expect(grant_types_supported).not.toContain('password'); + expect(response_types_supported).not.toContain('token'); + }); + + it('never advertises plain PKCE', () => { + expect( + buildAuthorizationServerMetadata().code_challenge_methods_supported, + ).toEqual(['S256']); + }); + + it('drops the mcp scope when no mcp resource is deployed', () => { + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(undefined); + + expect(buildAuthorizationServerMetadata().scopes_supported).toEqual([ + 'api', + ]); + }); +}); + +describe('getWellKnownPathVariants', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('also serves the issuer path-aware location required by RFC 8414 §3', () => { + jest.spyOn(oauthConfig, 'getIssuerUrl').mockReturnValue(ISSUER); + + expect( + getWellKnownPathVariants('/.well-known/oauth-authorization-server'), + ).toEqual([ + '/.well-known/oauth-authorization-server', + '/.well-known/oauth-authorization-server/api', + ]); + }); + + it('serves only the root location when the issuer has no path', () => { + jest + .spyOn(oauthConfig, 'getIssuerUrl') + .mockReturnValue('https://ops.example.com'); + + expect( + getWellKnownPathVariants('/.well-known/oauth-authorization-server'), + ).toEqual(['/.well-known/oauth-authorization-server']); + }); +}); diff --git a/packages/server/api/test/unit/oauth/oauth-principal.test.ts b/packages/server/api/test/unit/oauth/oauth-principal.test.ts new file mode 100644 index 0000000000..8a4591a22d --- /dev/null +++ b/packages/server/api/test/unit/oauth/oauth-principal.test.ts @@ -0,0 +1,382 @@ +import { PrincipalType } from '@openops/shared'; +import jwt from 'jsonwebtoken'; +import crypto from 'node:crypto'; + +const ISSUER = 'https://ops.example.com/api'; +const API_AUDIENCE = ISSUER; +const MCP_AUDIENCE = 'https://ops.example.com/mcp'; + +const { privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, +}); +const OAUTH_PRIVATE_KEY = privateKey.export({ + type: 'pkcs8', + format: 'pem', +}) as string; + +const activeGrant = { + id: 'grant-1', + userId: 'user-1', + clientId: 'client-1', + projectId: 'project-1', + scope: 'api', + status: 'active' as const, +}; + +const activeUser = { + id: 'user-1', + externalId: 'ext-1', + status: 'ACTIVE', + organizationId: 'org-1', + organizationRole: 'ADMIN', +}; + +const MEMBERSHIP = { + projectId: 'project-1', + organizationId: 'org-1', + projectRole: 'ADMIN', +}; + +jest.mock('../../../src/app/oauth/grants.service', () => ({ + grantsService: { + getActiveGrantOrThrow: jest.fn(async () => activeGrant), + touch: jest.fn(async () => undefined), + }, +})); + +jest.mock('../../../src/app/user/user-service', () => ({ + userService: { + get: jest.fn(async () => activeUser), + }, +})); + +const membershipService = { + getDefaultForUser: jest.fn(), + getForUser: jest.fn(), +}; + +jest.mock('../../../src/app/oauth/project-membership-factory', () => ({ + getOAuthProjectMembershipService: () => membershipService, +})); + +import { accessTokenManager } from '../../../src/app/authentication/context/access-token-manager'; +import { grantsService } from '../../../src/app/oauth/grants.service'; +import { oauthConfig } from '../../../src/app/oauth/oauth-config'; +import { invalidGrant, serverError } from '../../../src/app/oauth/oauth-errors'; +import { signingKeyService } from '../../../src/app/oauth/signing-key.service'; +import { userService } from '../../../src/app/user/user-service'; + +function signOAuthToken( + overrides: Record = {}, + options: jwt.SignOptions = {}, +): string { + return jwt.sign( + { + sub: 'user-1', + client_id: 'client-1', + scope: 'api', + grant_id: 'grant-1', + project_id: 'project-1', + jti: 'jti-1', + ...overrides, + }, + OAUTH_PRIVATE_KEY, + { + algorithm: 'RS256', + keyid: 'oauth-kid', + issuer: ISSUER, + audience: API_AUDIENCE, + expiresIn: 900, + ...options, + }, + ); +} + +describe('extractPrincipal with OAuth tokens', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(oauthConfig, 'isEnabled').mockReturnValue(true); + jest.spyOn(oauthConfig, 'getIssuerUrl').mockReturnValue(ISSUER); + jest.spyOn(oauthConfig, 'getApiAudience').mockReturnValue(API_AUDIENCE); + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(MCP_AUDIENCE); + + // Stand in for the real key store: verify against the test keypair, and + // enforce the audience exactly as the production implementation does. + jest + .spyOn(signingKeyService, 'verifyAccessToken') + .mockImplementation(async (token, expectedAudience) => { + const publicKey = crypto + .createPublicKey(OAUTH_PRIVATE_KEY) + .export({ type: 'spki', format: 'pem' }) as string; + try { + return jwt.verify(token, publicKey, { + algorithms: ['RS256'], + issuer: ISSUER, + audience: expectedAudience, + }) as Record; + } catch (error) { + // The real implementation reports a bad token as an OAuthError, and the + // caller distinguishes those from server faults. Mirror it here or this + // mock would exercise a contract the production code never sees. + throw invalidGrant((error as Error).message); + } + }); + + (grantsService.getActiveGrantOrThrow as jest.Mock).mockResolvedValue( + activeGrant, + ); + (userService.get as jest.Mock).mockResolvedValue(activeUser); + membershipService.getForUser.mockResolvedValue(MEMBERSHIP); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + /** + * SERVICE, never USER — and that is load-bearing, not cosmetic. + * + * Routes restricted to `PrincipalType.USER` are the ones that act on the session + * rather than within a project. Enterprise's `/switch-project` is one of them: it + * mints a token for a *different* project, and is deliberately exempt from the + * guard that rejects a request naming a project other than the principal's. The + * principal type is therefore the only thing stopping an OAuth connection from + * stepping outside the project its token names, which would make `project_id` + * meaningless. Do not widen this to USER. + */ + it('builds a SERVICE principal on the grant active project', async () => { + const principal = await accessTokenManager.extractPrincipal( + signOAuthToken(), + ); + + expect(principal).toEqual({ + id: 'user-1', + externalId: 'ext-1', + type: PrincipalType.SERVICE, + projectId: 'project-1', + projectRole: 'ADMIN', + organization: { id: 'org-1', role: 'ADMIN' }, + }); + }); + + it('acts on the project named by the token, not the one on the grant', async () => { + // The grant records what the connection was authorized for; the token decides + // what this particular credential may do. + (grantsService.getActiveGrantOrThrow as jest.Mock).mockResolvedValue({ + ...activeGrant, + projectId: 'project-1', + }); + membershipService.getForUser.mockResolvedValue({ + projectId: 'project-2', + organizationId: 'org-1', + projectRole: 'ADMIN', + }); + + const principal = await accessTokenManager.extractPrincipal( + signOAuthToken({ project_id: 'project-2' }), + ); + + expect(membershipService.getForUser).toHaveBeenCalledWith( + expect.objectContaining({ id: 'user-1' }), + 'project-2', + ); + expect(principal.projectId).toBe('project-2'); + }); + + it('rejects a token that names no project', async () => { + await expect( + accessTokenManager.extractPrincipal( + signOAuthToken({ project_id: undefined }), + ), + ).rejects.toThrow('INVALID_BEARER_TOKEN'); + }); + + it('carries the project role the membership reports', async () => { + membershipService.getForUser.mockResolvedValue({ + projectId: 'project-1', + organizationId: 'org-1', + projectRole: 'VIEWER', + }); + + const principal = await accessTokenManager.extractPrincipal( + signOAuthToken(), + ); + + expect(principal.projectRole).toBe('VIEWER'); + }); + + it('records last use so a direct connection is distinguishable in the list', async () => { + await accessTokenManager.extractPrincipal(signOAuthToken()); + + expect(grantsService.touch).toHaveBeenCalledWith('grant-1'); + }); + + it('rejects a token minted for the mcp resource server', async () => { + const mcpToken = signOAuthToken({}, { audience: MCP_AUDIENCE }); + + await expect(accessTokenManager.extractPrincipal(mcpToken)).rejects.toThrow( + 'INVALID_BEARER_TOKEN', + ); + }); + + it('rejects a token for an unrelated audience', async () => { + const foreignToken = signOAuthToken( + {}, + { audience: 'https://elsewhere.example' }, + ); + + await expect( + accessTokenManager.extractPrincipal(foreignToken), + ).rejects.toThrow('INVALID_BEARER_TOKEN'); + }); + + it('rejects a token from a different issuer', async () => { + const foreignIssuer = signOAuthToken( + {}, + { issuer: 'https://evil.example' }, + ); + + await expect( + accessTokenManager.extractPrincipal(foreignIssuer), + ).rejects.toThrow('INVALID_BEARER_TOKEN'); + }); + + it('rejects an expired token', async () => { + const expired = signOAuthToken({}, { expiresIn: -10 }); + + await expect(accessTokenManager.extractPrincipal(expired)).rejects.toThrow( + 'INVALID_BEARER_TOKEN', + ); + }); + + it('rejects a token signed by a foreign key', async () => { + const foreign = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const forged = jwt.sign( + { sub: 'attacker', grant_id: 'grant-1' }, + foreign.privateKey, + { + algorithm: 'RS256', + keyid: 'oauth-kid', + issuer: ISSUER, + audience: API_AUDIENCE, + expiresIn: 900, + }, + ); + + await expect(accessTokenManager.extractPrincipal(forged)).rejects.toThrow( + 'INVALID_BEARER_TOKEN', + ); + }); + + it('rejects when the grant has been revoked', async () => { + (grantsService.getActiveGrantOrThrow as jest.Mock).mockRejectedValue( + invalidGrant('the authorization for this client has been revoked'), + ); + + await expect( + accessTokenManager.extractPrincipal(signOAuthToken()), + ).rejects.toThrow('INVALID_BEARER_TOKEN'); + }); + + it('rejects when the token subject does not match the grant owner', async () => { + const otherUsersToken = signOAuthToken({ sub: 'user-2' }); + + await expect( + accessTokenManager.extractPrincipal(otherUsersToken), + ).rejects.toThrow('INVALID_BEARER_TOKEN'); + }); + + it('rejects when the user has been deactivated', async () => { + (userService.get as jest.Mock).mockResolvedValue({ + ...activeUser, + status: 'INACTIVE', + }); + + await expect( + accessTokenManager.extractPrincipal(signOAuthToken()), + ).rejects.toThrow('INVALID_BEARER_TOKEN'); + }); + + it('rejects when the user no longer exists', async () => { + (userService.get as jest.Mock).mockResolvedValue(null); + + await expect( + accessTokenManager.extractPrincipal(signOAuthToken()), + ).rejects.toThrow('INVALID_BEARER_TOKEN'); + }); + + it('rejects when the user has no access to the token project', async () => { + membershipService.getForUser.mockResolvedValue(null); + + await expect( + accessTokenManager.extractPrincipal(signOAuthToken()), + ).rejects.toThrow('INVALID_BEARER_TOKEN'); + }); + + it('rejects a token with no grant binding', async () => { + const unbound = signOAuthToken({ grant_id: undefined }); + + await expect(accessTokenManager.extractPrincipal(unbound)).rejects.toThrow( + 'INVALID_BEARER_TOKEN', + ); + }); + + describe('server faults are not reported as bad credentials', () => { + // An OAuth client that receives 401 discards its refresh token and re-runs + // authorization. A database blip must therefore not look like one. + it.each([ + ['the grant lookup', () => grantsService.getActiveGrantOrThrow], + ['the user lookup', () => userService.get], + ['the membership lookup', () => membershipService.getForUser], + ])( + 'propagates a failure in %s instead of returning 401', + async (_l, get) => { + (get() as jest.Mock).mockRejectedValue( + new Error('connection terminated unexpectedly'), + ); + + await expect( + accessTokenManager.extractPrincipal(signOAuthToken()), + ).rejects.toThrow('connection terminated unexpectedly'); + }, + ); + + it('propagates a signing-key store failure instead of returning 401', async () => { + (signingKeyService.verifyAccessToken as jest.Mock).mockRejectedValue( + serverError('OAuth signing key is not initialized'), + ); + + await expect( + accessTokenManager.extractPrincipal(signOAuthToken()), + ).rejects.toThrow('signing key is not initialized'); + }); + }); + + it('rejects OAuth tokens entirely when the feature is disabled', async () => { + jest.spyOn(oauthConfig, 'isEnabled').mockReturnValue(false); + + await expect( + accessTokenManager.extractPrincipal(signOAuthToken()), + ).rejects.toThrow('INVALID_BEARER_TOKEN'); + expect(grantsService.getActiveGrantOrThrow).not.toHaveBeenCalled(); + }); + + it('still accepts internal HS256 tokens, which never reach the OAuth path', async () => { + const internalToken = await accessTokenManager.generateToken({ + id: 'user-9', + type: PrincipalType.USER, + projectId: 'project-9', + projectRole: 'ADMIN', + organization: { id: 'org-9', role: 'ADMIN' }, + } as never); + + const principal = await accessTokenManager.extractPrincipal(internalToken); + + expect(principal).toMatchObject({ + id: 'user-9', + type: PrincipalType.USER, + projectId: 'project-9', + }); + expect(signingKeyService.verifyAccessToken).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/api/test/unit/oauth/pending-authorization.service.test.ts b/packages/server/api/test/unit/oauth/pending-authorization.service.test.ts new file mode 100644 index 0000000000..6d65d3dba9 --- /dev/null +++ b/packages/server/api/test/unit/oauth/pending-authorization.service.test.ts @@ -0,0 +1,332 @@ +import { OAuthError } from '../../../src/app/oauth/oauth-errors'; +import { OAuthPendingAuthorization } from '../../../src/app/oauth/oauth-model'; + +type PendingRow = OAuthPendingAuthorization; + +const rows: PendingRow[] = []; + +type Criteria = Record; + +/** + * `consumedAt: IsNull()` arrives as a TypeORM `FindOperator`, not a primitive. + * Honouring it here is what makes the single-use / concurrency assertions real: + * a mock that ignored the criterion would report every update as affecting a + * row and the anti-replay tests would pass vacuously. + */ +function matches(row: PendingRow, criteria: Criteria): boolean { + return Object.entries(criteria).every(([key, value]) => { + const actual = row[key as keyof PendingRow]; + + if (typeof value === 'string') { + return actual === value; + } + + const operator = value as { type?: string; value?: unknown }; + if (operator?.type === 'isNull') { + return actual === null; + } + if (operator?.type === 'lessThan') { + // Compared as instants: the service binds the cutoff as a `Date`. + const cutoff = operator.value; + if (!(cutoff instanceof Date)) { + throw new Error(`expected a Date cutoff for ${key}`); + } + return ( + typeof actual === 'string' && + new Date(actual).getTime() < cutoff.getTime() + ); + } + + throw new Error(`unsupported criteria for ${key}`); + }); +} + +jest.mock('../../../src/app/core/db/repo-factory', () => ({ + repoFactory: () => () => ({ + insert: async (row: PendingRow) => { + rows.push({ ...row }); + }, + findOneBy: async (criteria: Criteria) => + rows.find((row) => matches(row, criteria)) ?? null, + update: async (criteria: Criteria, patch: Partial) => { + const targets = rows.filter((row) => matches(row, criteria)); + targets.forEach((row) => Object.assign(row, patch)); + return { affected: targets.length }; + }, + delete: async (criteria: Criteria) => { + const targets = rows.filter((row) => matches(row, criteria)); + targets.forEach((row) => rows.splice(rows.indexOf(row), 1)); + return { affected: targets.length }; + }, + }), +})); + +import { + PENDING_AUTHORIZATION_TTL_MS, + pendingAuthorizationService, +} from '../../../src/app/oauth/pending-authorization.service'; + +const params = { + clientId: 'client-abc', + redirectUri: 'https://app.example.com/callback', + codeChallenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', + resource: 'https://ops.example.com/api', + scope: 'openops:read openops:write', + state: 'opaque-state', +}; + +function seedRow(overrides: Partial): PendingRow { + const now = new Date().toISOString(); + const row: PendingRow = { + id: 'seeded00000000000000A', + created: now, + updated: now, + ...params, + expiresAt: new Date( + Date.now() + PENDING_AUTHORIZATION_TTL_MS, + ).toISOString(), + consumedAt: null, + ...overrides, + }; + rows.push(row); + return row; +} + +async function descriptionOfRejection(promise: Promise) { + try { + await promise; + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + return (error as OAuthError).description; + } + throw new Error('expected the promise to reject'); +} + +describe('pendingAuthorizationService', () => { + beforeEach(() => { + rows.length = 0; + }); + + describe('create', () => { + it('persists every supplied parameter unmodified', async () => { + const id = await pendingAuthorizationService.create(params); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ id, ...params, consumedAt: null }); + }); + + it('returns an unguessable 21-character id', async () => { + const id = await pendingAuthorizationService.create(params); + + expect(id).toHaveLength(21); + expect(id).toMatch(/^[0-9a-zA-Z]{21}$/); + }); + + it('expires the request ten minutes after creation', async () => { + const before = Date.now(); + await pendingAuthorizationService.create(params); + + const expiresAt = new Date(rows[0].expiresAt).getTime(); + + expect(PENDING_AUTHORIZATION_TTL_MS).toBe(10 * 60 * 1000); + expect(expiresAt).toBeGreaterThanOrEqual( + before + PENDING_AUTHORIZATION_TTL_MS - 5000, + ); + expect(expiresAt).toBeLessThanOrEqual( + Date.now() + PENDING_AUTHORIZATION_TTL_MS + 5000, + ); + }); + + it('issues a distinct id per request', async () => { + const first = await pendingAuthorizationService.create(params); + const second = await pendingAuthorizationService.create(params); + + expect(first).not.toBe(second); + }); + }); + + describe('get', () => { + it('round-trips every validated parameter', async () => { + const id = await pendingAuthorizationService.create(params); + + const record = await pendingAuthorizationService.get(id); + + expect(record.id).toBe(id); + expect(record.clientId).toBe(params.clientId); + expect(record.redirectUri).toBe(params.redirectUri); + expect(record.codeChallenge).toBe(params.codeChallenge); + expect(record.resource).toBe(params.resource); + expect(record.scope).toBe(params.scope); + expect(record.state).toBe(params.state); + expect(record.consumedAt).toBeNull(); + }); + + it('round-trips a null state', async () => { + const id = await pendingAuthorizationService.create({ + ...params, + state: null, + }); + + const record = await pendingAuthorizationService.get(id); + + expect(record.state).toBeNull(); + }); + + it('rejects an unknown id', async () => { + await expect( + pendingAuthorizationService.get('doesNotExist00000000'), + ).rejects.toBeInstanceOf(OAuthError); + }); + + it('rejects a record whose expiry has passed', async () => { + const row = seedRow({ + expiresAt: new Date(Date.now() - 1000).toISOString(), + }); + + await expect(pendingAuthorizationService.get(row.id)).rejects.toThrow( + 'invalid_request', + ); + }); + + it('rejects an already-consumed record', async () => { + const row = seedRow({ consumedAt: new Date().toISOString() }); + + await expect(pendingAuthorizationService.get(row.id)).rejects.toThrow( + 'invalid_request', + ); + }); + + it('reports unknown, expired and consumed identically so ids cannot be probed', async () => { + const expired = seedRow({ + id: 'expired0000000000000', + expiresAt: new Date(Date.now() - 1000).toISOString(), + }); + const consumed = seedRow({ + id: 'consumed000000000000', + consumedAt: new Date().toISOString(), + }); + + const unknownDescription = await descriptionOfRejection( + pendingAuthorizationService.get('unknown00000000000000'), + ); + const expiredDescription = await descriptionOfRejection( + pendingAuthorizationService.get(expired.id), + ); + const consumedDescription = await descriptionOfRejection( + pendingAuthorizationService.get(consumed.id), + ); + + expect(expiredDescription).toBe(unknownDescription); + expect(consumedDescription).toBe(unknownDescription); + }); + }); + + describe('consume', () => { + it('returns the record and stamps consumedAt on the stored row', async () => { + const id = await pendingAuthorizationService.create(params); + + const record = await pendingAuthorizationService.consume(id); + + expect(record.id).toBe(id); + expect(record.clientId).toBe(params.clientId); + expect(record.redirectUri).toBe(params.redirectUri); + expect(record.codeChallenge).toBe(params.codeChallenge); + expect(rows[0].consumedAt).toEqual(expect.any(String)); + expect(new Date(rows[0].consumedAt as string).getTime()).not.toBeNaN(); + }); + + it('is single-use: a replayed consume of the same id is rejected', async () => { + const id = await pendingAuthorizationService.create(params); + + await pendingAuthorizationService.consume(id); + + await expect(pendingAuthorizationService.consume(id)).rejects.toThrow( + 'invalid_request', + ); + }); + + it('lets exactly one of two concurrent consumes succeed', async () => { + const id = await pendingAuthorizationService.create(params); + + const results = await Promise.allSettled([ + pendingAuthorizationService.consume(id), + pendingAuthorizationService.consume(id), + ]); + + expect( + results.filter((result) => result.status === 'fulfilled'), + ).toHaveLength(1); + expect( + results.filter((result) => result.status === 'rejected'), + ).toHaveLength(1); + }); + + it('rejects an expired record even though it was never consumed', async () => { + const row = seedRow({ + expiresAt: new Date(Date.now() - 1000).toISOString(), + }); + + await expect(pendingAuthorizationService.consume(row.id)).rejects.toThrow( + 'invalid_request', + ); + }); + + it('rejects an unknown id with the same description as a replay', async () => { + const id = await pendingAuthorizationService.create(params); + await pendingAuthorizationService.consume(id); + + const replayDescription = await descriptionOfRejection( + pendingAuthorizationService.consume(id), + ); + const unknownDescription = await descriptionOfRejection( + pendingAuthorizationService.consume('unknown00000000000000'), + ); + + expect(replayDescription).toBe(unknownDescription); + }); + }); + + describe('deleteExpired', () => { + it('removes only past-expiry rows and reports how many it deleted', async () => { + seedRow({ + id: 'expiredA000000000000', + expiresAt: new Date(Date.now() - 60_000).toISOString(), + }); + seedRow({ + id: 'expiredB000000000000', + expiresAt: new Date(Date.now() - 1000).toISOString(), + }); + const live = seedRow({ id: 'liveRow00000000000000' }); + + const deleted = await pendingAuthorizationService.deleteExpired(); + + expect(deleted).toBe(2); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe(live.id); + }); + + it('deletes nothing when every row is still live', async () => { + seedRow({ id: 'liveA0000000000000000' }); + seedRow({ id: 'liveB0000000000000000' }); + + const deleted = await pendingAuthorizationService.deleteExpired(); + + expect(deleted).toBe(0); + expect(rows).toHaveLength(2); + }); + + it('honours an explicit cutoff so a consumed-but-live row can be swept later', async () => { + const soon = seedRow({ + id: 'soon00000000000000000', + expiresAt: new Date(Date.now() + 1000).toISOString(), + }); + + const deleted = await pendingAuthorizationService.deleteExpired( + new Date(Date.now() + 60_000), + ); + + expect(deleted).toBe(1); + expect(rows.some((row) => row.id === soon.id)).toBe(false); + }); + }); +}); diff --git a/packages/server/api/test/unit/oauth/pkce.test.ts b/packages/server/api/test/unit/oauth/pkce.test.ts new file mode 100644 index 0000000000..8feff5e127 --- /dev/null +++ b/packages/server/api/test/unit/oauth/pkce.test.ts @@ -0,0 +1,45 @@ +import crypto from 'node:crypto'; +import { isValidCodeChallenge, verifyPkce } from '../../../src/app/oauth/pkce'; + +const VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; +const CHALLENGE = crypto + .createHash('sha256') + .update(VERIFIER) + .digest('base64url'); + +describe('verifyPkce', () => { + it('accepts a verifier whose S256 digest matches the challenge', () => { + expect(verifyPkce(VERIFIER, CHALLENGE)).toBe(true); + }); + + it('rejects a mismatched verifier', () => { + expect(verifyPkce(`${VERIFIER.slice(0, -1)}X`, CHALLENGE)).toBe(false); + }); + + it('rejects a plain-method verifier equal to the challenge', () => { + expect(verifyPkce(CHALLENGE, CHALLENGE)).toBe(false); + }); + + it.each([ + ['too short', 'short'], + ['too long', 'a'.repeat(129)], + ['illegal characters', `${'a'.repeat(42)}$`], + ['empty', ''], + ])('rejects a verifier that is %s', (_label, verifier) => { + expect(verifyPkce(verifier, CHALLENGE)).toBe(false); + }); +}); + +describe('isValidCodeChallenge', () => { + it('accepts a 43-char base64url challenge', () => { + expect(isValidCodeChallenge(CHALLENGE)).toBe(true); + }); + + it.each([ + ['wrong length', 'abc'], + ['base64 padding', `${'a'.repeat(42)}=`], + ['non-base64url characters', `${'a'.repeat(42)}+`], + ])('rejects a challenge with %s', (_label, challenge) => { + expect(isValidCodeChallenge(challenge)).toBe(false); + }); +}); diff --git a/packages/server/api/test/unit/oauth/project-membership.test.ts b/packages/server/api/test/unit/oauth/project-membership.test.ts new file mode 100644 index 0000000000..29ae7ed8e0 --- /dev/null +++ b/packages/server/api/test/unit/oauth/project-membership.test.ts @@ -0,0 +1,89 @@ +import { User } from '@openops/shared'; + +jest.mock('../../../src/app/project/project-service', () => ({ + projectService: { + getOneForUser: jest.fn(), + getOne: jest.fn(), + }, +})); + +import { oauthProjectMembershipService } from '../../../src/app/oauth/project-membership'; +import { getOAuthProjectMembershipService } from '../../../src/app/oauth/project-membership-factory'; +import { projectService } from '../../../src/app/project/project-service'; + +const USER = { id: 'user-1', organizationId: 'org-1' } as User; + +describe('oauthProjectMembershipService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getDefaultForUser', () => { + it('returns the organization project a new connection binds to', async () => { + (projectService.getOneForUser as jest.Mock).mockResolvedValue({ + id: 'project-1', + organizationId: 'org-1', + }); + + expect( + await oauthProjectMembershipService.getDefaultForUser(USER), + ).toEqual({ + projectId: 'project-1', + organizationId: 'org-1', + projectRole: 'ADMIN', + }); + }); + + it('returns null when the user has no project', async () => { + (projectService.getOneForUser as jest.Mock).mockResolvedValue(null); + + expect( + await oauthProjectMembershipService.getDefaultForUser(USER), + ).toBeNull(); + }); + }); + + describe('getForUser', () => { + it('authorizes a project in the user organization', async () => { + (projectService.getOne as jest.Mock).mockResolvedValue({ + id: 'project-1', + organizationId: 'org-1', + }); + + expect( + await oauthProjectMembershipService.getForUser(USER, 'project-1'), + ).toEqual({ + projectId: 'project-1', + organizationId: 'org-1', + projectRole: 'ADMIN', + }); + }); + + it('refuses a project in another organization', async () => { + (projectService.getOne as jest.Mock).mockResolvedValue({ + id: 'project-9', + organizationId: 'other-org', + }); + + expect( + await oauthProjectMembershipService.getForUser(USER, 'project-9'), + ).toBeNull(); + }); + + it('refuses a project that does not exist', async () => { + (projectService.getOne as jest.Mock).mockResolvedValue(null); + + expect( + await oauthProjectMembershipService.getForUser(USER, 'missing'), + ).toBeNull(); + }); + }); +}); + +describe('getOAuthProjectMembershipService', () => { + it('resolves to this edition implementation, and is the single seam an edition with real project membership replaces', () => { + expect(getOAuthProjectMembershipService()).toBe( + oauthProjectMembershipService, + ); + }); +}); diff --git a/packages/server/api/test/unit/oauth/redirect-uri.test.ts b/packages/server/api/test/unit/oauth/redirect-uri.test.ts new file mode 100644 index 0000000000..401dd7f746 --- /dev/null +++ b/packages/server/api/test/unit/oauth/redirect-uri.test.ts @@ -0,0 +1,100 @@ +import { + isRegistrableRedirectUri, + matchesRegisteredRedirectUri, +} from '../../../src/app/oauth/redirect-uri'; + +describe('isRegistrableRedirectUri', () => { + it.each([ + ['https callback', 'https://claude.ai/api/mcp/auth_callback', true], + ['ipv4 loopback with port', 'http://127.0.0.1:33418/callback', true], + ['localhost without port', 'http://localhost/cb', true], + ['ipv6 loopback', 'http://[::1]:8000/cb', true], + ['plain http host', 'http://evil.example.com/cb', false], + ['https with fragment', 'https://ok.example.com/cb#frag', false], + ['not a url', 'not-a-url', false], + ['empty string', '', false], + ['custom scheme', 'myapp://callback', false], + ['userinfo', 'https://user:pass@a.example/cb', false], + ['username only', 'https://user@a.example/cb', false], + ['over length limit', `https://a.example/${'x'.repeat(600)}`, false], + ])('%s -> %s', (_label, uri, expected) => { + expect(isRegistrableRedirectUri(uri)).toBe(expected); + }); +}); + +describe('matchesRegisteredRedirectUri', () => { + it('matches an identical https uri', () => { + expect( + matchesRegisteredRedirectUri( + ['https://a.example/cb'], + 'https://a.example/cb', + ), + ).toBe(true); + }); + + it.each([ + ['different path', 'https://a.example/cb2'], + ['different case', 'https://a.example/CB'], + ['added query', 'https://a.example/cb?x=1'], + ['different host', 'https://b.example/cb'], + ])('rejects https uri with %s', (_label, presented) => { + expect( + matchesRegisteredRedirectUri(['https://a.example/cb'], presented), + ).toBe(false); + }); + + it('matches loopback on a different port with the same host and path', () => { + expect( + matchesRegisteredRedirectUri( + ['http://127.0.0.1:1234/cb'], + 'http://127.0.0.1:9999/cb', + ), + ).toBe(true); + }); + + it('rejects loopback with a different path even on the registered port', () => { + expect( + matchesRegisteredRedirectUri( + ['http://127.0.0.1:1234/cb'], + 'http://127.0.0.1:1234/other', + ), + ).toBe(false); + }); + + it('does not let a loopback registration match a remote host', () => { + expect( + matchesRegisteredRedirectUri( + ['http://127.0.0.1:1234/cb'], + 'http://attacker.example/cb', + ), + ).toBe(false); + }); + + it.each([ + ['userinfo smuggled in', 'http://user:pass@127.0.0.1:9999/cb'], + ['a fragment appended', 'http://127.0.0.1:9999/cb#tail'], + ['an over-length value', `http://127.0.0.1:9999/cb#${'A'.repeat(600)}`], + ['a giant userinfo', `http://${'u'.repeat(700)}@127.0.0.1:9999/cb`], + ])('rejects a loopback uri with %s', (_label, presented) => { + // Loopback matching ignores the port, so these must be caught by the shape + // rules rather than by the comparison. + expect( + matchesRegisteredRedirectUri(['http://127.0.0.1:1234/cb'], presented), + ).toBe(false); + }); + + it('checks every registered uri', () => { + expect( + matchesRegisteredRedirectUri( + ['https://a.example/cb', 'https://b.example/cb'], + 'https://b.example/cb', + ), + ).toBe(true); + }); + + it('rejects when nothing is registered', () => { + expect(matchesRegisteredRedirectUri([], 'https://a.example/cb')).toBe( + false, + ); + }); +}); diff --git a/packages/server/api/test/unit/oauth/resource-registry.test.ts b/packages/server/api/test/unit/oauth/resource-registry.test.ts new file mode 100644 index 0000000000..4c5101fb29 --- /dev/null +++ b/packages/server/api/test/unit/oauth/resource-registry.test.ts @@ -0,0 +1,60 @@ +import { oauthConfig } from '../../../src/app/oauth/oauth-config'; +import { + getRegisteredResources, + getSupportedScopes, + resolveResource, +} from '../../../src/app/oauth/resource-registry'; + +const API_URI = 'https://ops.example.com/api'; +const MCP_URI = 'https://ops.example.com/mcp'; + +describe('resource-registry', () => { + beforeEach(() => { + jest.spyOn(oauthConfig, 'getApiAudience').mockReturnValue(API_URI); + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(MCP_URI); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('registers the api and mcp resources with their audiences and scopes', () => { + expect(getRegisteredResources()).toEqual([ + { + id: 'api', + audience: API_URI, + canonicalUri: API_URI, + scopes: ['api'], + }, + { + id: 'mcp', + audience: MCP_URI, + canonicalUri: MCP_URI, + scopes: ['mcp'], + }, + ]); + }); + + it('omits the mcp resource when no mcp url is configured', () => { + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(undefined); + + expect(getRegisteredResources().map((r) => r.id)).toEqual(['api']); + expect(resolveResource(MCP_URI)).toBeUndefined(); + }); + + it('resolves a resource by canonical uri, tolerating a trailing slash', () => { + expect(resolveResource(MCP_URI)?.id).toBe('mcp'); + expect(resolveResource(`${MCP_URI}/`)?.id).toBe('mcp'); + expect(resolveResource(API_URI)?.id).toBe('api'); + }); + + it('does not resolve unknown or empty resources', () => { + expect(resolveResource('https://elsewhere.example.com')).toBeUndefined(); + expect(resolveResource('')).toBeUndefined(); + expect(resolveResource(`${MCP_URI}/extra`)).toBeUndefined(); + }); + + it('lists every supported scope', () => { + expect(getSupportedScopes()).toEqual(['api', 'mcp']); + }); +}); diff --git a/packages/server/api/test/unit/oauth/signing-key.service.test.ts b/packages/server/api/test/unit/oauth/signing-key.service.test.ts new file mode 100644 index 0000000000..34563b4e82 --- /dev/null +++ b/packages/server/api/test/unit/oauth/signing-key.service.test.ts @@ -0,0 +1,309 @@ +import jwt from 'jsonwebtoken'; +import crypto from 'node:crypto'; + +const ISSUER = 'https://ops.example.com/api'; +const API_AUDIENCE = ISSUER; +const MCP_AUDIENCE = 'https://ops.example.com/mcp'; + +type KeyRow = { + id: string; + privateKeyEncrypted: string; + publicKeyPem: string; + status: string; +}; + +const keyRows: KeyRow[] = []; + +// Stand-in for AES: an invertible transform, so "the stored column holds no +// plaintext PEM" and "the service decrypts before signing" are both testable +// without depending on the real encryption key being loaded. +jest.mock('@openops/server-shared', () => { + const actual = jest.requireActual('@openops/server-shared'); + return { + ...actual, + encryptUtils: { + encryptString: (value: string) => ({ + iv: 'test-iv', + data: Buffer.from(value, 'utf-8').toString('base64'), + }), + decryptString: (encrypted: { data: string }) => + Buffer.from(encrypted.data, 'base64').toString('utf-8'), + }, + }; +}); + +jest.mock('../../../src/app/core/db/repo-factory', () => ({ + repoFactory: () => () => ({ + find: async () => [...keyRows], + findOneBy: async (query: { status: string }) => + keyRows.find((row) => row.status === query.status) ?? null, + insert: async (row: KeyRow) => { + if ( + row.status === 'active' && + keyRows.some((existing) => existing.status === 'active') + ) { + const error = new Error('duplicate key') as Error & { code: string }; + error.code = '23505'; + throw error; + } + keyRows.push(row); + }, + }), +})); + +import { oauthConfig } from '../../../src/app/oauth/oauth-config'; +import { signingKeyService } from '../../../src/app/oauth/signing-key.service'; + +describe('signingKeyService', () => { + beforeEach(() => { + keyRows.length = 0; + signingKeyService.clearKeyCacheForTests(); + jest.spyOn(oauthConfig, 'getIssuerUrl').mockReturnValue(ISSUER); + jest.spyOn(oauthConfig, 'getSigningKeyPemPath').mockReturnValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('generates exactly one active key and is idempotent across calls', async () => { + await signingKeyService.ensureSigningKey(); + await signingKeyService.ensureSigningKey(); + + expect(keyRows).toHaveLength(1); + expect(keyRows[0].status).toBe('active'); + expect(keyRows[0].publicKeyPem).toContain('BEGIN PUBLIC KEY'); + }); + + it('persists the private key only in encrypted form', async () => { + await signingKeyService.ensureSigningKey(); + + const stored = keyRows[0].privateKeyEncrypted; + + expect(stored).not.toContain('BEGIN PRIVATE KEY'); + expect(JSON.parse(stored).iv).toBe('test-iv'); + expect( + Buffer.from(JSON.parse(stored).data, 'base64').toString('utf-8'), + ).toContain('BEGIN PRIVATE KEY'); + }); + + it('publishes the public key as a JWKS entry with kid, alg and use', async () => { + await signingKeyService.ensureSigningKey(); + + const jwks = await signingKeyService.getJwks(); + + expect(jwks.keys).toHaveLength(1); + expect(jwks.keys[0]).toMatchObject({ + kty: 'RSA', + alg: 'RS256', + use: 'sig', + kid: keyRows[0].id, + }); + expect(jwks.keys[0].d).toBeUndefined(); + }); + + it('signs a token that verifies for the expected audience', async () => { + await signingKeyService.ensureSigningKey(); + + const token = await signingKeyService.signAccessToken( + { + sub: 'user-1', + aud: API_AUDIENCE, + client_id: 'client-1', + scope: 'api', + grant_id: 'grant-1', + project_id: 'project-1', + }, + 60, + ); + + const claims = await signingKeyService.verifyAccessToken( + token, + API_AUDIENCE, + ); + + expect(claims.sub).toBe('user-1'); + expect(claims.client_id).toBe('client-1'); + expect(claims.grant_id).toBe('grant-1'); + expect(claims.project_id).toBe('project-1'); + expect(claims.iss).toBe(ISSUER); + expect(claims.jti).toEqual(expect.any(String)); + expect(jwt.decode(token, { complete: true })?.header.alg).toBe('RS256'); + }); + + it('rejects a token whose audience is a different resource', async () => { + await signingKeyService.ensureSigningKey(); + const token = await signingKeyService.signAccessToken( + { + sub: 'user-1', + aud: MCP_AUDIENCE, + client_id: 'client-1', + scope: 'mcp', + grant_id: 'grant-1', + project_id: 'project-1', + }, + 60, + ); + + await expect( + signingKeyService.verifyAccessToken(token, API_AUDIENCE), + ).rejects.toThrow('invalid_grant'); + }); + + it('rejects an expired token', async () => { + await signingKeyService.ensureSigningKey(); + const token = await signingKeyService.signAccessToken( + { + sub: 'user-1', + aud: API_AUDIENCE, + client_id: 'client-1', + scope: 'api', + grant_id: 'grant-1', + project_id: 'project-1', + }, + -10, + ); + + await expect( + signingKeyService.verifyAccessToken(token, API_AUDIENCE), + ).rejects.toThrow('invalid_grant'); + }); + + it('rejects a token signed by a key it does not know', async () => { + await signingKeyService.ensureSigningKey(); + const foreign = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const forged = jwt.sign({ sub: 'attacker' }, foreign.privateKey, { + algorithm: 'RS256', + keyid: 'unknown-kid', + issuer: ISSUER, + audience: API_AUDIENCE, + expiresIn: 60, + }); + + await expect( + signingKeyService.verifyAccessToken(forged, API_AUDIENCE), + ).rejects.toThrow('unknown key'); + }); + + it('rejects an unsigned (alg=none) token', async () => { + await signingKeyService.ensureSigningKey(); + const header = Buffer.from( + JSON.stringify({ alg: 'none', typ: 'JWT', kid: keyRows[0].id }), + ).toString('base64url'); + const payload = Buffer.from( + JSON.stringify({ sub: 'attacker', aud: API_AUDIENCE, iss: ISSUER }), + ).toString('base64url'); + + await expect( + signingKeyService.verifyAccessToken( + `${header}.${payload}.`, + API_AUDIENCE, + ), + ).rejects.toThrow('invalid_grant'); + }); + + it('rejects an HS256 token forged with the public key as the secret', async () => { + await signingKeyService.ensureSigningKey(); + const forged = jwt.sign({ sub: 'attacker' }, keyRows[0].publicKeyPem, { + algorithm: 'HS256', + keyid: keyRows[0].id, + issuer: ISSUER, + audience: API_AUDIENCE, + expiresIn: 60, + }); + + await expect( + signingKeyService.verifyAccessToken(forged, API_AUDIENCE), + ).rejects.toThrow('invalid_grant'); + }); + + it('rejects a token with no key id', async () => { + await signingKeyService.ensureSigningKey(); + const token = jwt.sign({ sub: 'x' }, 'secret', { algorithm: 'HS256' }); + + await expect( + signingKeyService.verifyAccessToken(token, API_AUDIENCE), + ).rejects.toThrow('no key id'); + }); + + it('keeps verifying tokens signed by a retiring key after rotation', async () => { + await signingKeyService.ensureSigningKey(); + const oldKid = keyRows[0].id; + const tokenFromOldKey = await signingKeyService.signAccessToken( + { + sub: 'user-1', + aud: API_AUDIENCE, + client_id: 'client-1', + scope: 'api', + grant_id: 'grant-1', + project_id: 'project-1', + }, + 60, + ); + + // Rotate: demote the current key, add a new active one. + keyRows[0].status = 'retiring'; + signingKeyService.clearKeyCacheForTests(); + await signingKeyService.ensureSigningKey(); + + const newKid = keyRows.find((row) => row.status === 'active')?.id; + expect(newKid).not.toBe(oldKid); + + const claims = await signingKeyService.verifyAccessToken( + tokenFromOldKey, + API_AUDIENCE, + ); + expect(claims.sub).toBe('user-1'); + + const jwks = await signingKeyService.getJwks(); + expect(jwks.keys.map((key) => key.kid).sort()).toEqual( + [oldKid, newKid].sort(), + ); + + const tokenFromNewKey = await signingKeyService.signAccessToken( + { + sub: 'user-2', + aud: API_AUDIENCE, + client_id: 'client-1', + scope: 'api', + grant_id: 'grant-2', + project_id: 'project-1', + }, + 60, + ); + expect(jwt.decode(tokenFromNewKey, { complete: true })?.header.kid).toBe( + newKid, + ); + }); + + it('stops verifying tokens once their key is fully retired', async () => { + await signingKeyService.ensureSigningKey(); + const token = await signingKeyService.signAccessToken( + { + sub: 'user-1', + aud: API_AUDIENCE, + client_id: 'client-1', + scope: 'api', + grant_id: 'grant-1', + project_id: 'project-1', + }, + 60, + ); + + keyRows[0].status = 'retiring'; + signingKeyService.clearKeyCacheForTests(); + await signingKeyService.ensureSigningKey(); + keyRows.find((row) => row.status === 'retiring')!.status = 'retired'; + signingKeyService.clearKeyCacheForTests(); + + await expect( + signingKeyService.verifyAccessToken(token, API_AUDIENCE), + ).rejects.toThrow('unknown key'); + }); + + it('fails clearly when no key has been initialized', async () => { + await expect(signingKeyService.getJwks()).rejects.toThrow( + 'not initialized', + ); + }); +}); diff --git a/packages/server/api/test/unit/oauth/token-exchange.test.ts b/packages/server/api/test/unit/oauth/token-exchange.test.ts new file mode 100644 index 0000000000..1538077c9b --- /dev/null +++ b/packages/server/api/test/unit/oauth/token-exchange.test.ts @@ -0,0 +1,409 @@ +const API_URI = 'https://ops.example.com/api'; +const MCP_URI = 'https://ops.example.com/mcp'; +const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'; +const TOKEN_EXCHANGE_GRANT_TYPE = + 'urn:ietf:params:oauth:grant-type:token-exchange'; +const BASIC_HEADER = `Basic ${Buffer.from('openops-mcp-rs:secret').toString( + 'base64', +)}`; + +const RS_CLIENT = { + id: 'openops-mcp-rs', + clientName: 'OpenOps MCP Resource Server', + redirectUris: [], + grantTypes: [TOKEN_EXCHANGE_GRANT_TYPE], + tokenEndpointAuthMethod: 'client_secret_basic' as const, + clientSecretHash: 'x'.repeat(64), + scope: 'mcp', +}; + +const MCP_GRANT = { + id: 'grant-1', + userId: 'user-1', + clientId: 'client-1', + projectId: 'project-1', + scope: 'mcp', + status: 'active' as const, +}; + +jest.mock('../../../src/app/oauth/clients.service', () => ({ + TOKEN_EXCHANGE_GRANT: 'urn:ietf:params:oauth:grant-type:token-exchange', + clientsService: { + authenticateResourceServerClient: jest.fn(), + assertGrantTypeAllowed: jest.fn(), + }, +})); + +jest.mock('../../../src/app/oauth/grants.service', () => ({ + grantsService: { + getActiveGrantOrThrow: jest.fn(), + touch: jest.fn(), + }, +})); + +jest.mock('../../../src/app/oauth/tokens.service', () => ({ + tokensService: { + mintExchangedApiToken: jest.fn(), + }, +})); + +jest.mock('../../../src/app/oauth/signing-key.service', () => ({ + signingKeyService: { + verifyAccessToken: jest.fn(), + }, +})); + +jest.mock('../../../src/app/user/user-service', () => ({ + userService: { + get: jest.fn(), + }, +})); + +const membershipService = { + getDefaultForUser: jest.fn(), + getForUser: jest.fn(), +}; + +jest.mock('../../../src/app/oauth/project-membership-factory', () => ({ + getOAuthProjectMembershipService: () => membershipService, +})); + +import { + clientsService, + TOKEN_EXCHANGE_GRANT, +} from '../../../src/app/oauth/clients.service'; +import { grantsService } from '../../../src/app/oauth/grants.service'; +import { oauthConfig } from '../../../src/app/oauth/oauth-config'; +import { OAuthError } from '../../../src/app/oauth/oauth-errors'; +import { signingKeyService } from '../../../src/app/oauth/signing-key.service'; +import { + exchangeToken, + ExchangeTokenParams, +} from '../../../src/app/oauth/token-exchange'; +import { tokensService } from '../../../src/app/oauth/tokens.service'; +import { userService } from '../../../src/app/user/user-service'; + +const authenticateMock = + clientsService.authenticateResourceServerClient as jest.Mock; +const assertGrantTypeMock = clientsService.assertGrantTypeAllowed as jest.Mock; +const verifyAccessTokenMock = signingKeyService.verifyAccessToken as jest.Mock; +const getActiveGrantMock = grantsService.getActiveGrantOrThrow as jest.Mock; +const touchMock = grantsService.touch as jest.Mock; +const mintMock = tokensService.mintExchangedApiToken as jest.Mock; +const userGetMock = userService.get as jest.Mock; +const getForUserMock = membershipService.getForUser as jest.Mock; + +function exchangeParams( + overrides: Partial = {}, +): ExchangeTokenParams { + return { + authorizationHeader: BASIC_HEADER, + subjectToken: 'mcp-audience-token', + ...overrides, + }; +} + +describe('exchangeToken', () => { + beforeEach(() => { + jest.clearAllMocks(); + + jest.spyOn(oauthConfig, 'getApiAudience').mockReturnValue(API_URI); + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(MCP_URI); + + authenticateMock.mockResolvedValue(RS_CLIENT); + assertGrantTypeMock.mockReturnValue(undefined); + verifyAccessTokenMock.mockResolvedValue({ + sub: 'user-1', + aud: MCP_URI, + client_id: 'client-1', + scope: 'mcp', + grant_id: 'grant-1', + project_id: 'project-1', + }); + getActiveGrantMock.mockResolvedValue(MCP_GRANT); + touchMock.mockResolvedValue(undefined); + mintMock.mockResolvedValue({ + accessToken: 'api-audience-token', + expiresIn: 300, + }); + userGetMock.mockResolvedValue({ + id: 'user-1', + status: 'ACTIVE', + organizationId: 'org-1', + }); + getForUserMock.mockResolvedValue({ + projectId: 'project-1', + organizationId: 'org-1', + projectRole: 'ADMIN', + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns a separate api-audience token for a verified mcp token', async () => { + const response = await exchangeToken(exchangeParams()); + + expect(response).toEqual({ + access_token: 'api-audience-token', + issued_token_type: ACCESS_TOKEN_TYPE, + token_type: 'Bearer', + expires_in: 300, + scope: 'api', + }); + expect(response.access_token).not.toBe('mcp-audience-token'); + expect(mintMock).toHaveBeenCalledWith({ + grant: { id: 'grant-1', userId: 'user-1', clientId: 'client-1' }, + scope: 'api', + projectId: 'project-1', + }); + }); + + it('records usage on the grant', async () => { + await exchangeToken(exchangeParams()); + + expect(touchMock).toHaveBeenCalledWith('grant-1'); + }); + + it('requires the subject token to carry the mcp audience, never the api audience', async () => { + await exchangeToken(exchangeParams()); + + expect(verifyAccessTokenMock).toHaveBeenCalledWith( + 'mcp-audience-token', + MCP_URI, + ); + expect(verifyAccessTokenMock.mock.calls[0][1]).not.toBe(API_URI); + }); + + it('only allows a client registered for the token-exchange grant', async () => { + await exchangeToken(exchangeParams()); + + expect(assertGrantTypeMock).toHaveBeenCalledWith( + RS_CLIENT, + TOKEN_EXCHANGE_GRANT, + ); + }); + + it('authenticates the client before touching the subject token', async () => { + authenticateMock.mockRejectedValue( + new OAuthError('invalid_client', 'client authentication failed', 401), + ); + + await expect(exchangeToken(exchangeParams())).rejects.toThrow( + 'client authentication failed', + ); + expect(verifyAccessTokenMock).not.toHaveBeenCalled(); + expect(mintMock).not.toHaveBeenCalled(); + }); + + it('rejects a client that is not allowed the token-exchange grant', async () => { + assertGrantTypeMock.mockImplementation(() => { + throw new OAuthError( + 'unauthorized_client', + `client is not authorized to use grant type ${TOKEN_EXCHANGE_GRANT_TYPE}`, + ); + }); + + await expect(exchangeToken(exchangeParams())).rejects.toThrow( + 'not authorized to use grant type', + ); + expect(verifyAccessTokenMock).not.toHaveBeenCalled(); + expect(mintMock).not.toHaveBeenCalled(); + }); + + it('rejects an unsupported subject_token_type', async () => { + await expect( + exchangeToken( + exchangeParams({ + subjectTokenType: 'urn:ietf:params:oauth:token-type:id_token', + }), + ), + ).rejects.toMatchObject({ errorCode: 'invalid_request' }); + expect(mintMock).not.toHaveBeenCalled(); + }); + + it('accepts an explicit access-token subject_token_type', async () => { + const response = await exchangeToken( + exchangeParams({ subjectTokenType: ACCESS_TOKEN_TYPE }), + ); + + expect(response.access_token).toBe('api-audience-token'); + }); + + it('rejects a subject token that fails verification', async () => { + verifyAccessTokenMock.mockRejectedValue( + new OAuthError('invalid_grant', 'token verification failed: jwt expired'), + ); + + await expect(exchangeToken(exchangeParams())).rejects.toThrow( + 'token verification failed', + ); + expect(mintMock).not.toHaveBeenCalled(); + }); + + it('rejects a subject token that carries no grant_id', async () => { + verifyAccessTokenMock.mockResolvedValue({ sub: 'user-1', aud: MCP_URI }); + + await expect(exchangeToken(exchangeParams())).rejects.toThrow( + 'token is not bound to an authorization', + ); + expect(getActiveGrantMock).not.toHaveBeenCalled(); + expect(mintMock).not.toHaveBeenCalled(); + }); + + it('rejects when the grant has been revoked', async () => { + getActiveGrantMock.mockRejectedValue( + new OAuthError( + 'invalid_grant', + 'the authorization for this client has been revoked', + ), + ); + + await expect(exchangeToken(exchangeParams())).rejects.toThrow('revoked'); + expect(mintMock).not.toHaveBeenCalled(); + }); + + it('rejects when the user is no longer active', async () => { + userGetMock.mockResolvedValue({ + id: 'user-1', + status: 'INACTIVE', + organizationId: 'org-1', + }); + + await expect(exchangeToken(exchangeParams())).rejects.toThrow( + 'no longer active', + ); + expect(mintMock).not.toHaveBeenCalled(); + }); + + it('rejects when the user no longer exists', async () => { + userGetMock.mockResolvedValue(null); + + await expect(exchangeToken(exchangeParams())).rejects.toThrow( + 'no longer active', + ); + expect(mintMock).not.toHaveBeenCalled(); + }); + + it('rejects when the user has no access to the project', async () => { + getForUserMock.mockResolvedValue(null); + + await expect(exchangeToken(exchangeParams())).rejects.toMatchObject({ + errorCode: 'invalid_target', + description: 'the requested project is not accessible', + }); + expect(mintMock).not.toHaveBeenCalled(); + }); + + it('authorizes the project named by the subject token, per request', async () => { + await exchangeToken(exchangeParams()); + + expect(getForUserMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'user-1' }), + 'project-1', + ); + }); + + it('inherits the project from the subject token, not from the grant', async () => { + // A subject token minted for project-2 must not be widened to the grant's + // project: the two tokens always refer to the same project. + verifyAccessTokenMock.mockResolvedValue({ + sub: 'user-1', + aud: MCP_URI, + client_id: 'client-1', + scope: 'mcp', + grant_id: 'grant-1', + project_id: 'project-2', + }); + getForUserMock.mockResolvedValue({ + projectId: 'project-2', + organizationId: 'org-1', + projectRole: 'ADMIN', + }); + + await exchangeToken(exchangeParams()); + + expect(getForUserMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'user-1' }), + 'project-2', + ); + expect(mintMock).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'project-2' }), + ); + }); + + it('acts in a requested project instead of the subject token one', async () => { + // How an agent switches project: the resource server names where it wants to act, + // and the exchange decides whether it may. + getForUserMock.mockResolvedValue({ + projectId: 'project-9', + organizationId: 'org-1', + projectRole: 'ADMIN', + }); + + await exchangeToken(exchangeParams({ requestedProjectId: 'project-9' })); + + expect(getForUserMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'user-1' }), + 'project-9', + ); + expect(mintMock).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'project-9' }), + ); + }); + + it('refuses a requested project the user is not a member of', async () => { + getForUserMock.mockResolvedValue(null); + + // The whole safety of switching rests here. Without this check a resource server + // could mint itself a token for any project it cared to name. + await expect( + exchangeToken(exchangeParams({ requestedProjectId: 'someone-elses' })), + ).rejects.toMatchObject({ errorCode: 'invalid_target' }); + expect(mintMock).not.toHaveBeenCalled(); + }); + + it('checks membership for the requested project, not the subject token one', async () => { + getForUserMock.mockResolvedValue({ + projectId: 'project-9', + organizationId: 'org-1', + projectRole: 'ADMIN', + }); + + await exchangeToken(exchangeParams({ requestedProjectId: 'project-9' })); + + // Verifying the wrong project would authorize a switch on the strength of access + // to the project being switched away from. + expect(getForUserMock).not.toHaveBeenCalledWith( + expect.anything(), + 'project-1', + ); + }); + + it('rejects a subject token that names no project', async () => { + verifyAccessTokenMock.mockResolvedValue({ + sub: 'user-1', + aud: MCP_URI, + client_id: 'client-1', + scope: 'mcp', + grant_id: 'grant-1', + }); + + await expect(exchangeToken(exchangeParams())).rejects.toMatchObject({ + errorCode: 'invalid_grant', + }); + expect(mintMock).not.toHaveBeenCalled(); + }); + + it('rejects when no mcp resource is configured', async () => { + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(undefined); + + await expect(exchangeToken(exchangeParams())).rejects.toMatchObject({ + errorCode: 'invalid_target', + description: 'the mcp resource is not configured', + }); + expect(verifyAccessTokenMock).not.toHaveBeenCalled(); + expect(mintMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/api/test/unit/oauth/tokens.service.test.ts b/packages/server/api/test/unit/oauth/tokens.service.test.ts new file mode 100644 index 0000000000..710ab30054 --- /dev/null +++ b/packages/server/api/test/unit/oauth/tokens.service.test.ts @@ -0,0 +1,747 @@ +import crypto from 'node:crypto'; + +const API_URI = 'https://ops.example.com/api'; +const MCP_URI = 'https://ops.example.com/mcp'; +const CODE_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; +const CODE_CHALLENGE = crypto + .createHash('sha256') + .update(CODE_VERIFIER) + .digest('base64url'); + +type Row = Record; + +const codeRows: Row[] = []; +const refreshRows: Row[] = []; + +function matches(row: Row, criteria: Row): boolean { + return Object.entries(criteria).every(([key, value]) => { + if (value instanceof Object && value.constructor.name === 'FindOperator') { + return row[key] === null || row[key] === undefined; + } + return row[key] === value; + }); +} + +function makeRepo(store: Row[]) { + return () => ({ + find: async (options?: { where?: Row }) => + store.filter((row) => matches(row, options?.where ?? {})), + findOneBy: async (criteria: Row) => + store.find((row) => matches(row, criteria)) ?? null, + insert: async (row: Row) => { + store.push(row); + }, + update: async (criteria: Row, patch: Row) => { + const targets = store.filter((row) => matches(row, criteria)); + for (const target of targets) { + Object.assign(target, patch); + } + return { affected: targets.length }; + }, + }); +} + +jest.mock('../../../src/app/core/db/repo-factory', () => ({ + repoFactory: (entity: { options: { name: string } }) => + entity.options.name === 'oauth_authorization_code' + ? makeRepo(codeRows) + : makeRepo(refreshRows), +})); + +const MEMBERSHIP = { + projectId: 'project-1', + organizationId: 'org-1', + projectRole: 'ADMIN', +}; + +const mockGrant = { + id: 'grant-1', + userId: 'user-1', + clientId: 'client-1', + projectId: 'project-1', + scope: 'mcp', + status: 'active' as const, +}; + +jest.mock('../../../src/app/oauth/grants.service', () => ({ + grantsService: { + create: jest.fn(async () => mockGrant), + getActiveGrantOrThrow: jest.fn(async () => mockGrant), + getGrantSnapshot: jest.fn(async () => mockGrant), + revoke: jest.fn(async () => undefined), + }, +})); + +jest.mock('../../../src/app/user/user-service', () => ({ + userService: { + get: jest.fn(async () => ({ + id: 'user-1', + status: 'ACTIVE', + organizationId: 'org-1', + })), + }, +})); + +type Membership = typeof MEMBERSHIP | null; + +const membershipService = { + getDefaultForUser: jest.fn, unknown[]>(), + getForUser: jest.fn, unknown[]>(), +}; + +jest.mock('../../../src/app/oauth/project-membership-factory', () => ({ + getOAuthProjectMembershipService: () => membershipService, +})); + +import { grantsService } from '../../../src/app/oauth/grants.service'; +import { oauthConfig } from '../../../src/app/oauth/oauth-config'; +import { sha256Hex } from '../../../src/app/oauth/oauth-crypto'; +import { OAuthPendingAuthorization } from '../../../src/app/oauth/oauth-model'; +import { signingKeyService } from '../../../src/app/oauth/signing-key.service'; +import { tokensService } from '../../../src/app/oauth/tokens.service'; +import { userService } from '../../../src/app/user/user-service'; + +const PENDING: OAuthPendingAuthorization = { + id: 'pending-1', + created: new Date().toISOString(), + updated: new Date().toISOString(), + clientId: 'client-1', + redirectUri: 'https://client.example/cb', + codeChallenge: CODE_CHALLENGE, + resource: MCP_URI, + scope: 'mcp', + state: 'state-1', + expiresAt: new Date(Date.now() + 600_000).toISOString(), + consumedAt: null, +}; + +function redeemParams(overrides: Partial> = {}) { + return { + code: 'unset', + clientId: 'client-1', + redirectUri: 'https://client.example/cb', + codeVerifier: CODE_VERIFIER, + resource: MCP_URI, + ...overrides, + } as Parameters[0]; +} + +describe('tokensService', () => { + beforeEach(() => { + codeRows.length = 0; + refreshRows.length = 0; + jest.clearAllMocks(); + + jest.spyOn(oauthConfig, 'getApiAudience').mockReturnValue(API_URI); + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(MCP_URI); + jest.spyOn(oauthConfig, 'getAccessTokenTtlSeconds').mockReturnValue(900); + jest.spyOn(oauthConfig, 'getRefreshTokenTtlDays').mockReturnValue(30); + jest.spyOn(oauthConfig, 'getExchangeTokenTtlSeconds').mockReturnValue(300); + jest + .spyOn(signingKeyService, 'signAccessToken') + .mockImplementation(async (claims, ttl) => + JSON.stringify({ ...claims, ttl }), + ); + (grantsService.create as jest.Mock).mockResolvedValue(mockGrant); + (grantsService.getActiveGrantOrThrow as jest.Mock).mockResolvedValue( + mockGrant, + ); + (grantsService.getGrantSnapshot as jest.Mock).mockResolvedValue(mockGrant); + (userService.get as jest.Mock).mockResolvedValue({ + id: 'user-1', + status: 'ACTIVE', + organizationId: 'org-1', + }); + membershipService.getDefaultForUser.mockResolvedValue(MEMBERSHIP); + // Echoes the project it is asked about, like the real service. Returning a fixed + // membership would make every caller look correct no matter which project it passed. + membershipService.getForUser.mockImplementation( + async (_user: unknown, projectId: unknown) => ({ + ...MEMBERSHIP, + projectId: projectId as string, + }), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('issueAuthorizationCode', () => { + it('stores only a hash of the code and copies the validated parameters', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + + expect(codeRows).toHaveLength(1); + expect(codeRows[0].codeHash).toBe(sha256Hex(code)); + expect(Object.values(codeRows[0])).not.toContain(code); + expect(codeRows[0]).toMatchObject({ + clientId: 'client-1', + userId: 'user-1', + redirectUri: 'https://client.example/cb', + codeChallenge: CODE_CHALLENGE, + resource: MCP_URI, + scope: 'mcp', + consumedAt: null, + }); + }); + + it('expires the code within a minute', async () => { + await tokensService.issueAuthorizationCode(PENDING, 'user-1'); + + const expiresAt = new Date(codeRows[0].expiresAt as string).getTime(); + expect(expiresAt - Date.now()).toBeLessThanOrEqual(60_000); + expect(expiresAt - Date.now()).toBeGreaterThan(50_000); + }); + }); + + describe('redeemAuthorizationCode', () => { + it('returns an access token and refresh token for a valid redemption', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + + const response = await tokensService.redeemAuthorizationCode( + redeemParams({ code }), + ); + + expect(response).toMatchObject({ + token_type: 'Bearer', + expires_in: 900, + scope: 'mcp', + }); + expect(response.refresh_token).toEqual(expect.any(String)); + expect(JSON.parse(response.access_token)).toMatchObject({ + sub: 'user-1', + aud: MCP_URI, + client_id: 'client-1', + scope: 'mcp', + grant_id: 'grant-1', + }); + }); + + it('records the project on the refresh token, not the grant', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + + await tokensService.redeemAuthorizationCode(redeemParams({ code })); + + // The chain carries it forward, which is what lets a plain renewal stay where the + // connection currently is. + expect(refreshRows[0].projectId).toBe('project-1'); + }); + + it('pins the project into the token claims', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + + const response = await tokensService.redeemAuthorizationCode( + redeemParams({ code }), + ); + + expect(JSON.parse(response.access_token).project_id).toBe('project-1'); + }); + + it('binds the access token to the mcp audience, never the api audience', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + + const response = await tokensService.redeemAuthorizationCode( + redeemParams({ code }), + ); + + expect(JSON.parse(response.access_token).aud).not.toBe(API_URI); + }); + + it('stores the refresh token hashed, with a fresh family', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + + const response = await tokensService.redeemAuthorizationCode( + redeemParams({ code }), + ); + + expect(refreshRows).toHaveLength(1); + expect(refreshRows[0].tokenHash).toBe( + sha256Hex(response.refresh_token as string), + ); + expect(Object.values(refreshRows[0])).not.toContain( + response.refresh_token, + ); + expect(refreshRows[0].familyId).toEqual(expect.any(String)); + expect(refreshRows[0].grantId).toBe('grant-1'); + }); + + it('activates the grant only on redemption', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + expect(grantsService.create).not.toHaveBeenCalled(); + + await tokensService.redeemAuthorizationCode(redeemParams({ code })); + + expect(grantsService.create).toHaveBeenCalledWith({ + clientId: 'client-1', + userId: 'user-1', + resourceId: 'mcp', + }); + }); + + it('rejects a replayed code and issues no second token', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + await tokensService.redeemAuthorizationCode(redeemParams({ code })); + + await expect( + tokensService.redeemAuthorizationCode(redeemParams({ code })), + ).rejects.toThrow('invalid or expired authorization code'); + expect(refreshRows).toHaveLength(1); + }); + + it('lets exactly one of two concurrent redemptions succeed', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + + const results = await Promise.allSettled([ + tokensService.redeemAuthorizationCode(redeemParams({ code })), + tokensService.redeemAuthorizationCode(redeemParams({ code })), + ]); + + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + expect(results.filter((r) => r.status === 'rejected')).toHaveLength(1); + expect(refreshRows).toHaveLength(1); + }); + + it('rejects an unknown code', async () => { + await expect( + tokensService.redeemAuthorizationCode(redeemParams({ code: 'nope' })), + ).rejects.toThrow('invalid or expired authorization code'); + }); + + it('rejects an expired code', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + codeRows[0].expiresAt = new Date(Date.now() - 1000).toISOString(); + + await expect( + tokensService.redeemAuthorizationCode(redeemParams({ code })), + ).rejects.toThrow('invalid or expired authorization code'); + expect(refreshRows).toHaveLength(0); + }); + + it.each([ + ['a different client', { clientId: 'other-client' }], + ['a different redirect uri', { redirectUri: 'https://evil.example/cb' }], + ['a different resource', { resource: API_URI }], + ['an unknown resource', { resource: 'https://elsewhere.example' }], + ['a wrong pkce verifier', { codeVerifier: 'x'.repeat(43) }], + ])('rejects redemption with %s', async (_label, overrides) => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + + await expect( + tokensService.redeemAuthorizationCode( + redeemParams({ code, ...overrides }), + ), + ).rejects.toThrow('invalid or expired authorization code'); + expect(refreshRows).toHaveLength(0); + }); + + it('rejects redemption for a deactivated user', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + (userService.get as jest.Mock).mockResolvedValue({ + id: 'user-1', + status: 'INACTIVE', + }); + + await expect( + tokensService.redeemAuthorizationCode(redeemParams({ code })), + ).rejects.toThrow('no longer active'); + expect(refreshRows).toHaveLength(0); + }); + }); + + describe('rotateRefreshToken', () => { + async function issueInitialTokens(): Promise { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + const response = await tokensService.redeemAuthorizationCode( + redeemParams({ code }), + ); + return response.refresh_token as string; + } + + it('issues a new pair and revokes the presented token', async () => { + const original = await issueInitialTokens(); + + const rotated = await tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }); + + expect(rotated.refresh_token).not.toBe(original); + expect(refreshRows).toHaveLength(2); + expect(refreshRows[0].revokedAt).toEqual(expect.any(String)); + expect(refreshRows[1].revokedAt).toBeNull(); + }); + + it('keeps the rotated token in the same family', async () => { + const original = await issueInitialTokens(); + + await tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }); + + expect(refreshRows[1].familyId).toBe(refreshRows[0].familyId); + }); + + it('revokes the entire family when a rotated token is presented again', async () => { + const original = await issueInitialTokens(); + const rotated = await tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).rejects.toThrow('refresh token reuse detected'); + + // The legitimate client's current token is dead too: the whole chain is + // untrusted once a replay is observed. + await expect( + tokensService.rotateRefreshToken({ + refreshToken: rotated.refresh_token as string, + clientId: 'client-1', + }), + ).rejects.toThrow('refresh token reuse detected'); + expect(refreshRows.every((row) => row.revokedAt !== null)).toBe(true); + }); + + it('reports a revoked connection as revoked, not as a replay', async () => { + const original = await issueInitialTokens(); + // Revoking a grant also revokes its tokens, so the claim fails for a + // reason that is not an attack. + refreshRows[0].revokedAt = new Date().toISOString(); + (grantsService.getGrantSnapshot as jest.Mock).mockResolvedValue({ + ...mockGrant, + status: 'revoked', + }); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).rejects.toThrow('has been revoked'); + }); + + it('refuses to refresh once the user loses access to the project', async () => { + const original = await issueInitialTokens(); + membershipService.getForUser.mockResolvedValue(null); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).rejects.toThrow('not accessible'); + }); + + it('re-authorizes the project on every rotation', async () => { + const original = await issueInitialTokens(); + membershipService.getForUser.mockClear(); + + await tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }); + + expect(membershipService.getForUser).toHaveBeenCalledWith( + expect.objectContaining({ id: 'user-1' }), + 'project-1', + ); + }); + + it('switches the connection to a requested project', async () => { + const original = await issueInitialTokens(); + membershipService.getForUser.mockResolvedValue({ + projectId: 'project-2', + organizationId: 'org-1', + projectRole: 'ADMIN', + }); + + const rotated = await tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + requestedProjectId: 'project-2', + }); + + expect(membershipService.getForUser).toHaveBeenCalledWith( + expect.objectContaining({ id: 'user-1' }), + 'project-2', + ); + expect(JSON.parse(rotated.access_token).project_id).toBe('project-2'); + }); + + it('refuses a requested project the user is not a member of', async () => { + const original = await issueInitialTokens(); + membershipService.getForUser.mockResolvedValue(null); + + // invalid_target rather than invalid_grant: the client asked for something + // specific and may not have it, which is a correctable request. + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + requestedProjectId: 'someone-elses', + }), + ).rejects.toMatchObject({ errorCode: 'invalid_target' }); + }); + + it('leaves the refresh token usable when a switch is refused', async () => { + const original = await issueInitialTokens(); + membershipService.getForUser.mockResolvedValue(null); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + requestedProjectId: 'someone-elses', + }), + ).rejects.toMatchObject({ errorCode: 'invalid_target' }); + + // A rejected switch must not cost the connection its credential. Consuming the + // token here would brick a working agent for asking the wrong question, and the + // retry would look like a replay. + membershipService.getForUser.mockResolvedValue(MEMBERSHIP); + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).resolves.toEqual( + expect.objectContaining({ access_token: expect.any(String) }), + ); + }); + + it('keeps a switched project across a later plain refresh', async () => { + const original = await issueInitialTokens(); + + const switched = await tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + requestedProjectId: 'project-2', + }); + + // The renewal a client performs on its own schedule, naming no project. + const renewed = await tokensService.rotateRefreshToken({ + refreshToken: switched.refresh_token as string, + clientId: 'client-1', + }); + + // Must not fall back to where the connection started. Renewing a credential + // should hand back an equivalent one; quietly moving the agent to another + // project mid-run would be near-impossible to attribute. + expect(JSON.parse(renewed.access_token).project_id).toBe('project-2'); + }); + + it('stays where it is when no project is requested', async () => { + const original = await issueInitialTokens(); + membershipService.getForUser.mockClear(); + + const rotated = await tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }); + + expect(membershipService.getForUser).toHaveBeenCalledWith( + expect.anything(), + 'project-1', + ); + expect(JSON.parse(rotated.access_token).project_id).toBe('project-1'); + }); + + it('rejects an unknown refresh token', async () => { + await expect( + tokensService.rotateRefreshToken({ + refreshToken: 'nope', + clientId: 'client-1', + }), + ).rejects.toThrow('invalid refresh token'); + }); + + it('rejects rotation by a different client without destroying the token', async () => { + const original = await issueInitialTokens(); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'other-client', + }), + ).rejects.toThrow('invalid refresh token'); + + // A rejected request must leave the credential usable, or the real client's + // next attempt would look like a replay and kill the whole connection. + expect(refreshRows[0].revokedAt).toBeNull(); + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).resolves.toMatchObject({ token_type: 'Bearer' }); + }); + + it('rejects an expired refresh token without consuming it', async () => { + const original = await issueInitialTokens(); + refreshRows[0].expiresAt = new Date(Date.now() - 1000).toISOString(); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).rejects.toThrow('expired'); + expect(refreshRows[0].revokedAt).toBeNull(); + }); + + it('does not revoke the family when the project is no longer accessible', async () => { + const original = await issueInitialTokens(); + membershipService.getForUser.mockResolvedValue(null); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).rejects.toThrow('not accessible'); + expect(refreshRows[0].revokedAt).toBeNull(); + }); + + it('refuses to refresh once the grant is revoked', async () => { + const original = await issueInitialTokens(); + (grantsService.getActiveGrantOrThrow as jest.Mock).mockRejectedValue( + new Error('the authorization for this client has been revoked'), + ); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).rejects.toThrow('revoked'); + }); + + it('survives a transient failure so the retry is not read as a replay', async () => { + const original = await issueInitialTokens(); + (userService.get as jest.Mock).mockRejectedValueOnce( + new Error('connection terminated unexpectedly'), + ); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).rejects.toThrow('connection terminated'); + + expect(refreshRows[0].revokedAt).toBeNull(); + + const retry = await tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }); + + expect(retry.refresh_token).toEqual(expect.any(String)); + expect(refreshRows.every((row) => row.revokedAt !== null)).toBe(false); + }); + + it('refuses to refresh for a deactivated user', async () => { + const original = await issueInitialTokens(); + (userService.get as jest.Mock).mockResolvedValue({ + id: 'user-1', + status: 'INACTIVE', + }); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).rejects.toThrow('no longer active'); + }); + }); + + describe('revokeByRefreshToken', () => { + it('revokes the grant behind the token', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + const response = await tokensService.redeemAuthorizationCode( + redeemParams({ code }), + ); + + await tokensService.revokeByRefreshToken( + response.refresh_token as string, + ); + + expect(grantsService.revoke).toHaveBeenCalledWith('grant-1'); + }); + + it('ignores an unknown token, as RFC 7009 requires', async () => { + await expect( + tokensService.revokeByRefreshToken('unknown'), + ).resolves.toBeUndefined(); + expect(grantsService.revoke).not.toHaveBeenCalled(); + }); + }); + + describe('mintExchangedApiToken', () => { + it('mints a short-lived api-audience token for the grant', async () => { + const result = await tokensService.mintExchangedApiToken({ + grant: mockGrant, + scope: 'api', + projectId: 'project-7', + }); + + expect(result.expiresIn).toBe(300); + expect(JSON.parse(result.accessToken)).toMatchObject({ + sub: 'user-1', + aud: API_URI, + grant_id: 'grant-1', + scope: 'api', + project_id: 'project-7', + ttl: 300, + }); + }); + }); +}); diff --git a/packages/server/shared/src/lib/system/system-prop.ts b/packages/server/shared/src/lib/system/system-prop.ts index 0ccd4c8596..edbebb027c 100644 --- a/packages/server/shared/src/lib/system/system-prop.ts +++ b/packages/server/shared/src/lib/system/system-prop.ts @@ -51,6 +51,15 @@ export enum AppSystemProp { JWT_TOKEN_LIFETIME_HOURS = 'JWT_TOKEN_LIFETIME_HOURS', TABLES_TOKEN_LIFETIME_MINUTES = 'TABLES_TOKEN_LIFETIME_MINUTES', + OAUTH_ENABLED = 'OAUTH_ENABLED', + OAUTH_ISSUER_URL = 'OAUTH_ISSUER_URL', + OAUTH_ACCESS_TOKEN_TTL_SECONDS = 'OAUTH_ACCESS_TOKEN_TTL_SECONDS', + OAUTH_REFRESH_TOKEN_TTL_DAYS = 'OAUTH_REFRESH_TOKEN_TTL_DAYS', + OAUTH_EXCHANGE_TOKEN_TTL_SECONDS = 'OAUTH_EXCHANGE_TOKEN_TTL_SECONDS', + OAUTH_SIGNING_KEY_PEM_PATH = 'OAUTH_SIGNING_KEY_PEM_PATH', + OAUTH_RS_CLIENT_SECRET = 'OAUTH_RS_CLIENT_SECRET', + MCP_RESOURCE_URL = 'MCP_RESOURCE_URL', + // ENTERPRISE ONLY FIREBASE_ADMIN_CREDENTIALS = 'FIREBASE_ADMIN_CREDENTIALS', FIREBASE_HASH_PARAMETERS = 'FIREBASE_HASH_PARAMETERS', diff --git a/packages/server/shared/src/lib/system/system.ts b/packages/server/shared/src/lib/system/system.ts index 5441eccd4b..3c5fe2808f 100644 --- a/packages/server/shared/src/lib/system/system.ts +++ b/packages/server/shared/src/lib/system/system.ts @@ -81,6 +81,10 @@ const systemPropDefaultValues: Partial> = { [AppSystemProp.ANALYTICS_ENABLED]: 'true', [SharedSystemProp.EXECUTION_MODE]: 'SANDBOX_CODE_ONLY', [AppSystemProp.JWT_TOKEN_LIFETIME_HOURS]: '168', + [AppSystemProp.OAUTH_ENABLED]: 'false', + [AppSystemProp.OAUTH_ACCESS_TOKEN_TTL_SECONDS]: '900', + [AppSystemProp.OAUTH_REFRESH_TOKEN_TTL_DAYS]: '30', + [AppSystemProp.OAUTH_EXCHANGE_TOKEN_TTL_SECONDS]: '300', [AppSystemProp.DARK_THEME_ENABLED]: 'false', [AppSystemProp.SHOW_DEMO_HOME_PAGE]: 'false', [AppSystemProp.SEED_DEV_DATA]: 'false', diff --git a/packages/shared/src/lib/flag/flag.ts b/packages/shared/src/lib/flag/flag.ts index b7670480e9..1ef48be7be 100644 --- a/packages/shared/src/lib/flag/flag.ts +++ b/packages/shared/src/lib/flag/flag.ts @@ -62,4 +62,5 @@ export enum FlagId { FEDERATED_LOGIN_ENABLED = 'FEDERATED_LOGIN_ENABLED', FINOPS_BENCHMARK_ENABLED = 'FINOPS_BENCHMARK_ENABLED', ANALYTICS_DASHBOARDS = 'ANALYTICS_DASHBOARDS', + CONNECTED_APPS_ENABLED = 'CONNECTED_APPS_ENABLED', } diff --git a/packages/ui-components/src/components/confirmation-dialog/confirmation-dialog.tsx b/packages/ui-components/src/components/confirmation-dialog/confirmation-dialog.tsx index 859925a60f..e7deba4b52 100644 --- a/packages/ui-components/src/components/confirmation-dialog/confirmation-dialog.tsx +++ b/packages/ui-components/src/components/confirmation-dialog/confirmation-dialog.tsx @@ -1,5 +1,5 @@ import { t } from 'i18next'; -import { Button } from '../../ui/button'; +import { Button, ButtonProps } from '../../ui/button'; import { Dialog, DialogContent, @@ -17,6 +17,12 @@ type ConfirmationDialogProps = { className?: string; titleClassName?: string; descriptionClassName?: string; + /** + * Styling for the confirm action. Defaults to the primary button; pass + * `destructive` when confirming destroys something, so the dialog matches the + * control that opened it. + */ + confirmButtonVariant?: ButtonProps['variant']; children?: React.ReactNode; }; @@ -38,6 +44,7 @@ const ConfirmationDialog = ({ onCancel, titleClassName, descriptionClassName, + confirmButtonVariant, children, }: ConfirmationDialogProps & ConfirmationDialogContent) => { return ( @@ -56,7 +63,7 @@ const ConfirmationDialog = ({ {cancelButtonText ? cancelButtonText : t('Cancel')} )} - diff --git a/tools/oauth-flow.sh b/tools/oauth-flow.sh new file mode 100755 index 0000000000..c8874e8b78 --- /dev/null +++ b/tools/oauth-flow.sh @@ -0,0 +1,181 @@ +#!/bin/bash +# +# Walks the external-agent OAuth flow end to end against a locally running API. +# See docs/oauth-manual-testing.md. +# +# Usage: +# tools/oauth-flow.sh # api resource (direct REST access, like a CLI) +# tools/oauth-flow.sh mcp # mcp resource (adds the token-exchange step) +# +set -euo pipefail + +RESOURCE_KIND="${1:-api}" +API="${OPS_OAUTH_TEST_API:-http://localhost:3000}" +EMAIL="${OPS_OAUTH_TEST_EMAIL:-local-admin@openops.com}" +PASSWORD="${OPS_OAUTH_TEST_PASSWORD:-12345678}" +MCP_RESOURCE="${OPS_MCP_RESOURCE_URL:-http://localhost:3020/mcp}" +RS_SECRET="${OPS_OAUTH_RS_CLIENT_SECRET:-}" +REDIRECT="http://127.0.0.1:41100/callback" + +# A fixed PKCE pair. Real clients generate one per request; a constant keeps this +# script readable and is not a weakness here because nothing is at stake locally. +VERIFIER="dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" +CHALLENGE="E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +say() { printf '\n\033[1m%s\033[0m\n' "$*"; } +fail() { printf '\033[31mFAILED: %s\033[0m\n' "$*" >&2; exit 1; } + +claims() { + local jwt="$1" + python3 -c " +import base64, json, sys +payload = sys.argv[1].split('.')[1] +payload += '=' * (-len(payload) % 4) +decoded = json.loads(base64.urlsafe_b64decode(payload)) +shown = {k: decoded[k] for k in ('aud','sub','scope','grant_id','project_id') if k in decoded} +print(json.dumps(shown, indent=2))" "$jwt" +} + +json_get() { + local file="$1" key="$2" + python3 -c "import json,sys;print(json.load(open(sys.argv[1]))[sys.argv[2]])" "$file" "$key" +} + +# ---------------------------------------------------------------- preflight --- +say "Preflight" +curl -sf -o /dev/null "$API/v1/flags" || fail "API not reachable at $API" +if ! curl -sf -o /dev/null "$API/.well-known/oauth-authorization-server"; then + fail "OAuth is disabled. Start the API with OPS_OAUTH_ENABLED=true (see docs/oauth-manual-testing.md)" +fi +echo " API up, OAuth enabled" + +if [[ "$RESOURCE_KIND" == "mcp" ]]; then + RESOURCE="$MCP_RESOURCE" + [[ -n "$RS_SECRET" ]] || fail "mcp mode needs OPS_OAUTH_RS_CLIENT_SECRET (same value the API was started with)" + curl -s "$API/.well-known/oauth-authorization-server" | + grep -q '"mcp"' || fail "the API has no mcp resource configured (set OPS_MCP_RESOURCE_URL)" +else + RESOURCE="$(curl -s "$API/.well-known/oauth-authorization-server" | + python3 -c "import sys,json;print(json.load(sys.stdin)['issuer'])")" +fi +echo " resource: $RESOURCE" + +# --------------------------------------------------------------- discovery --- +say "1. Discovery (what a client reads first)" +curl -s "$API/.well-known/oauth-authorization-server" | python3 -m json.tool | head -14 +echo " jwks keys: $(curl -s "$API/v1/oauth/jwks.json" | + python3 -c "import sys,json;d=json.load(sys.stdin);print(len(d['keys']), d['keys'][0]['alg'])")" + +# ------------------------------------------------------------ registration --- +say "2. Dynamic client registration" +curl -s -X POST "$API/v1/oauth/register" -H 'Content-Type: application/json' \ + -d "{\"client_name\":\"Manual Test Client\",\"redirect_uris\":[\"$REDIRECT\"]}" \ + -o "$WORK_DIR/client.json" +CLIENT_ID="$(json_get "$WORK_DIR/client.json" client_id)" +echo " client_id: $CLIENT_ID" + +# --------------------------------------------------------------- authorize --- +say "3. Authorize (a real client opens this in a browser)" +AUTHORIZE_URL="$API/v1/oauth/authorize?client_id=$CLIENT_ID&redirect_uri=$( + python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$REDIRECT" +)&response_type=code&code_challenge=$CHALLENGE&code_challenge_method=S256&resource=$( + python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$RESOURCE" +)&state=manual-test-state" +LOCATION="$(curl -s -i "$AUTHORIZE_URL" | grep -i '^location:' | tr -d '\r' | sed 's/^[Ll]ocation: //')" +echo " browser would be sent to: $LOCATION" +REQUEST_ID="$(printf '%s' "$LOCATION" | sed -n 's/.*request_id=\([^&]*\).*/\1/p')" +[[ -n "$REQUEST_ID" ]] || fail "no request_id in the redirect — check the authorize parameters" + +# ------------------------------------------------------------------ consent --- +say "4. Consent (a browser would show the dialog on Settings -> Connected apps; driven directly here)" +curl -s -c "$WORK_DIR/cookies" -X POST "$API/v1/authentication/sign-in" \ + -H 'Content-Type: application/json' \ + -d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" -o /dev/null || + fail "sign-in failed for $EMAIL" + +echo " what the consent screen would show:" +curl -s -b "$WORK_DIR/cookies" "$API/v1/oauth/requests/$REQUEST_ID" | python3 -m json.tool | sed 's/^/ /' + +curl -s -b "$WORK_DIR/cookies" -X POST "$API/v1/oauth/requests/$REQUEST_ID/decision" \ + -H 'Content-Type: application/json' -H 'x-openops-consent: 1' \ + -d '{"approve":true}' -o "$WORK_DIR/decision.json" +CODE="$(python3 -c " +import json, urllib.parse as u +q = u.parse_qs(u.urlparse(json.load(open('$WORK_DIR/decision.json'))['redirectTo']).query) +print(q['code'][0])")" +echo " approved; code issued (state and iss are echoed back to the client)" + +# -------------------------------------------------------------------- token --- +say "5. Redeem the code" +curl -s -X POST "$API/v1/oauth/token" \ + -d "grant_type=authorization_code&code=$CODE&client_id=$CLIENT_ID&redirect_uri=$REDIRECT&code_verifier=$VERIFIER&resource=$RESOURCE" \ + -o "$WORK_DIR/tokens.json" +grep -q access_token "$WORK_DIR/tokens.json" || fail "$(cat "$WORK_DIR/tokens.json")" +ACCESS_TOKEN="$(json_get "$WORK_DIR/tokens.json" access_token)" +REFRESH_TOKEN="$(json_get "$WORK_DIR/tokens.json" refresh_token)" +echo " claims in the client's token:" +claims "$ACCESS_TOKEN" | sed 's/^/ /' + +# ------------------------------------------------------- use it on the API --- +if [[ "$RESOURCE_KIND" == "mcp" ]]; then + say "6. Token exchange (what the MCP resource server does per tool call)" + BASIC="$(printf 'openops-mcp-rs:%s' "$RS_SECRET" | base64 | tr -d '\n')" + echo " the client's own token must NOT work against the API:" + echo " HTTP $(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $ACCESS_TOKEN" "$API/v1/flows") (expect 401)" + curl -s -X POST "$API/v1/oauth/token" -H "Authorization: Basic $BASIC" \ + -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=$ACCESS_TOKEN" \ + -o "$WORK_DIR/exchange.json" + grep -q access_token "$WORK_DIR/exchange.json" || fail "$(cat "$WORK_DIR/exchange.json")" + API_TOKEN="$(json_get "$WORK_DIR/exchange.json" access_token)" + echo " exchanged for a separate API-audience token:" + claims "$API_TOKEN" | sed 's/^/ /' +else + API_TOKEN="$ACCESS_TOKEN" +fi + +say "7. Call the API with it" +PROJECT_ID="$(python3 -c " +import base64, json +p = '$API_TOKEN'.split('.')[1]; p += '=' * (-len(p) % 4) +print(json.loads(base64.urlsafe_b64decode(p))['project_id'])")" +STATUS="$(curl -s -o "$WORK_DIR/flows.json" -w '%{http_code}' \ + -H "Authorization: Bearer $API_TOKEN" "$API/v1/flows?projectId=$PROJECT_ID")" +echo " GET /v1/flows -> HTTP $STATUS" +[[ "$STATUS" == "200" ]] || fail "the token was refused by the API" + +# ------------------------------------------------------------------ refresh --- +say "8. Refresh, and confirm the old token is single-use" +curl -s -X POST "$API/v1/oauth/token" \ + -d "grant_type=refresh_token&refresh_token=$REFRESH_TOKEN&client_id=$CLIENT_ID" \ + -o "$WORK_DIR/rotated.json" +grep -q access_token "$WORK_DIR/rotated.json" || fail "$(cat "$WORK_DIR/rotated.json")" +ROTATED_REFRESH="$(json_get "$WORK_DIR/rotated.json" refresh_token)" +echo " rotated; new refresh token issued" +echo " replaying the old one: $(curl -s -X POST "$API/v1/oauth/token" \ + -d "grant_type=refresh_token&refresh_token=$REFRESH_TOKEN&client_id=$CLIENT_ID" | + python3 -c "import sys,json;print(json.load(sys.stdin)['error_description'])")" +echo " (that also kills the rotated token — a replay means the chain is untrusted)" + +# ----------------------------------------------------- connections + revoke --- +say "9. Connected apps, and revoking one" +curl -s -b "$WORK_DIR/cookies" "$API/v1/oauth/grants" | python3 -c " +import sys, json +for g in json.load(sys.stdin)['data']: + print(f\" {g['clientName']} grant={g['id']} via={g['resourceId']} last used={g['lastUsedAt']}\")" + +GRANT_ID="$(python3 -c " +import base64, json +p = '$API_TOKEN'.split('.')[1]; p += '=' * (-len(p) % 4) +print(json.loads(base64.urlsafe_b64decode(p))['grant_id'])")" +curl -s -b "$WORK_DIR/cookies" -X DELETE "$API/v1/oauth/grants/$GRANT_ID" -o /dev/null +echo " revoked grant $GRANT_ID" +echo " API call with its still-unexpired token: HTTP $(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $API_TOKEN" "$API/v1/flows?projectId=$PROJECT_ID") (expect 401)" +echo " refresh after revocation: $(curl -s -X POST "$API/v1/oauth/token" \ + -d "grant_type=refresh_token&refresh_token=$ROTATED_REFRESH&client_id=$CLIENT_ID" | + python3 -c "import sys,json;print(json.load(sys.stdin)['error_description'])")" + +say "Done — full flow verified for the '$RESOURCE_KIND' resource."