From 956ff8bafe9718f3b988bc9d61f2e12a73c40221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 28 Jul 2026 14:42:36 +0100 Subject: [PATCH 01/25] Add OAuth configuration, entities and schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the tables backing an OAuth 2.1 authorization server: signing keys, registered clients, pending authorization requests, authorization codes, refresh tokens, and grants (one per authorized connection). Configuration is validated before any OAuth route is served. Each check exists because the misconfiguration it catches would otherwise leave a server that looks healthy while a guarantee is silently gone — most importantly, an MCP resource whose audience equals the API's, which would let the resource server accept tokens the API issued. Part of OPS-4673. --- .../src/app/database/database-connection.ts | 14 ++ .../1785312000000-CreateOAuthTables.ts | 182 ++++++++++++++++++ .../src/app/database/postgres-connection.ts | 2 + .../src/app/oauth/oauth-config-validation.ts | 90 +++++++++ .../server/api/src/app/oauth/oauth-config.ts | 42 ++++ .../server/api/src/app/oauth/oauth-model.ts | 111 +++++++++++ .../server/api/src/app/oauth/oauth.entity.ts | 157 +++++++++++++++ .../oauth/oauth-config-validation.test.ts | 87 +++++++++ .../api/test/unit/oauth/oauth-config.test.ts | 47 +++++ .../shared/src/lib/system/system-prop.ts | 9 + .../server/shared/src/lib/system/system.ts | 4 + 11 files changed, 745 insertions(+) create mode 100644 packages/server/api/src/app/database/migrations/1785312000000-CreateOAuthTables.ts create mode 100644 packages/server/api/src/app/oauth/oauth-config-validation.ts create mode 100644 packages/server/api/src/app/oauth/oauth-config.ts create mode 100644 packages/server/api/src/app/oauth/oauth-model.ts create mode 100644 packages/server/api/src/app/oauth/oauth.entity.ts create mode 100644 packages/server/api/test/unit/oauth/oauth-config-validation.test.ts create mode 100644 packages/server/api/test/unit/oauth/oauth-config.test.ts 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..60a8029e21 --- /dev/null +++ b/packages/server/api/src/app/database/migrations/1785312000000-CreateOAuthTables.ts @@ -0,0 +1,182 @@ +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), + "scope" varchar(128) NOT NULL, + 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, + "projectId" varchar(21) NOT NULL, + "resourceId" varchar(32) NOT NULL, + "scope" varchar(128) 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, + "userId" varchar(21) NOT NULL, + "resource" varchar(512) NOT NULL, + "scope" varchar(128) 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/oauth/oauth-config-validation.ts b/packages/server/api/src/app/oauth/oauth-config-validation.ts new file mode 100644 index 0000000000..ea7f666bfe --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth-config-validation.ts @@ -0,0 +1,90 @@ +import { AppSystemProp, DatabaseType, system } from '@openops/server-shared'; +import { ApplicationError, ErrorCode } from '@openops/shared'; +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()}${url.pathname.replace( + /\/+$/, + '', + )}`; + } catch { + return audience; + } +} + +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()); + + 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..c292254eb5 --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth-config.ts @@ -0,0 +1,42 @@ +import { AppSystemProp, system } from '@openops/server-shared'; + +function stripTrailingSlashes(value: string): string { + return value.replace(/\/+$/, ''); +} + +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-model.ts b/packages/server/api/src/app/oauth/oauth-model.ts new file mode 100644 index 0000000000..36511fa645 --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth-model.ts @@ -0,0 +1,111 @@ +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'; + +export type OAuthClient = BaseModel & { + clientName: string; + redirectUris: string[]; + grantTypes: string[]; + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod; + clientSecretHash: string | null; + scope: string; +}; + +/** + * 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; +}; + +export type OAuthRefreshToken = BaseModel & { + tokenHash: string; + grantId: string; + /** Shared by every token rotated from the same original issuance. */ + familyId: string; + clientId: string; + userId: string; + resource: string; + scope: 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. + * + * `projectId` is fixed when the authorization is granted, matching the project + * the user was signed in to. Multi-project access is an enterprise capability + * layered on top; the OSS server issues tokens for exactly one project and never + * mutates that choice. + */ +export type OAuthGrant = BaseModel & { + clientId: string; + userId: string; + projectId: string; + resourceId: string; + scope: 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.entity.ts b/packages/server/api/src/app/oauth/oauth.entity.ts new file mode 100644 index 0000000000..397deb5653 --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth.entity.ts @@ -0,0 +1,157 @@ +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, + }, + scope: { type: String, length: 128 }, + }, + 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 }, + userId: { ...OpenOpsIdSchema }, + resource: { type: String, length: URI_LENGTH }, + scope: { type: String, length: 128 }, + 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 }, + projectId: { ...OpenOpsIdSchema }, + resourceId: { type: String, length: 32 }, + scope: { type: String, length: 128 }, + 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/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..f0884f7ff5 --- /dev/null +++ b/packages/server/api/test/unit/oauth/oauth-config-validation.test.ts @@ -0,0 +1,87 @@ +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('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..d6020cfcf7 --- /dev/null +++ b/packages/server/api/test/unit/oauth/oauth-config.test.ts @@ -0,0 +1,47 @@ +import { 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 TTLs from the configured defaults', () => { + expect(oauthConfig.getAccessTokenTtlSeconds()).toBe(900); + expect(oauthConfig.getRefreshTokenTtlDays()).toBe(30); + expect(oauthConfig.getExchangeTokenTtlSeconds()).toBe(300); + }); + + it('is disabled unless explicitly enabled', () => { + expect(oauthConfig.isEnabled()).toBe(false); + + jest.spyOn(system, 'getBoolean').mockReturnValue(true); + expect(oauthConfig.isEnabled()).toBe(true); + }); +}); 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', From b6f62ab67c8e4cdcd90ae6bc3c0f6bff6c21760b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 28 Jul 2026 14:42:49 +0100 Subject: [PATCH 02/25] Add OAuth protocol primitives RFC 6749 error responses, opaque-token generation and timing-safe comparison, PKCE S256 verification, redirect-URI rules, and the resource registry that binds each RFC 8707 resource to a token audience. Redirect URIs are held to the same shape rules whether they arrive at registration or on an authorize request: https or loopback only, no fragment, no userinfo, and bounded length. Loopback matching ignores the port because native clients bind an ephemeral one, so those rules are what stop a presented URI carrying more than the registered one allowed. Project resolution sits behind a factory, following the convention used elsewhere in the security layer, so an edition with real per-project membership supplies its own lookups without the OAuth code changing. Part of OPS-4673. --- .../server/api/src/app/oauth/oauth-crypto.ts | 21 ++++ .../server/api/src/app/oauth/oauth-errors.ts | 46 ++++++++ .../server/api/src/app/oauth/oauth-query.ts | 17 +++ packages/server/api/src/app/oauth/pkce.ts | 27 +++++ .../app/oauth/project-membership-factory.ts | 8 ++ .../api/src/app/oauth/project-membership.ts | 71 +++++++++++++ .../server/api/src/app/oauth/redirect-uri.ts | 84 +++++++++++++++ .../api/src/app/oauth/resource-registry.ts | 58 ++++++++++ .../api/test/unit/oauth/oauth-crypto.test.ts | 36 +++++++ .../api/test/unit/oauth/oauth-errors.test.ts | 40 +++++++ .../server/api/test/unit/oauth/pkce.test.ts | 45 ++++++++ .../unit/oauth/project-membership.test.ts | 89 ++++++++++++++++ .../api/test/unit/oauth/redirect-uri.test.ts | 100 ++++++++++++++++++ .../test/unit/oauth/resource-registry.test.ts | 60 +++++++++++ 14 files changed, 702 insertions(+) create mode 100644 packages/server/api/src/app/oauth/oauth-crypto.ts create mode 100644 packages/server/api/src/app/oauth/oauth-errors.ts create mode 100644 packages/server/api/src/app/oauth/oauth-query.ts create mode 100644 packages/server/api/src/app/oauth/pkce.ts create mode 100644 packages/server/api/src/app/oauth/project-membership-factory.ts create mode 100644 packages/server/api/src/app/oauth/project-membership.ts create mode 100644 packages/server/api/src/app/oauth/redirect-uri.ts create mode 100644 packages/server/api/src/app/oauth/resource-registry.ts create mode 100644 packages/server/api/test/unit/oauth/oauth-crypto.test.ts create mode 100644 packages/server/api/test/unit/oauth/oauth-errors.test.ts create mode 100644 packages/server/api/test/unit/oauth/pkce.test.ts create mode 100644 packages/server/api/test/unit/oauth/project-membership.test.ts create mode 100644 packages/server/api/test/unit/oauth/redirect-uri.test.ts create mode 100644 packages/server/api/test/unit/oauth/resource-registry.test.ts 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-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/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..162bfc787d --- /dev/null +++ b/packages/server/api/src/app/oauth/project-membership.ts @@ -0,0 +1,71 @@ +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 two 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 = { + /** Which project a newly authorized connection is bound to. */ + 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; +}; + +// 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, + }; + }, +}; 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..4ed37665dd --- /dev/null +++ b/packages/server/api/src/app/oauth/resource-registry.ts @@ -0,0 +1,58 @@ +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 = resource.replace(/\/+$/, ''); + return getRegisteredResources().find((r) => r.canonicalUri === normalized); +} + +export function getSupportedScopes(): string[] { + return getRegisteredResources().flatMap((r) => r.scopes); +} 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/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']); + }); +}); From c1f7a8917b05187bfa6e8d65b1f5cdd3ae8e16a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 28 Jul 2026 14:43:03 +0100 Subject: [PATCH 03/25] Add OAuth issuance, revocation and token exchange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth-issued tokens are signed with a dedicated RS256 keypair, generated on first boot and published at a real JWKS, so a resource server can verify them locally and neither trust domain's key can forge the other's tokens. Keys are cached, and a stale copy is served through a database outage rather than failing verification of tokens that are still perfectly valid. Single-use credentials are claimed with a conditional UPDATE, so a replay loses the race rather than being detected after the fact. Refresh tokens rotate within a family and a replayed token revokes the family, per OAuth 2.1 — but only after the request has been validated, because revoking on the way in would let one rejected request destroy a working credential and make the client's retry look like an attack. Each authorization becomes its own connection, so a user can connect the same agent more than once and revoke either independently. Revoking cascades to that connection's refresh tokens, since a grant alone would leave the client able to mint access tokens by refreshing. Part of OPS-4673. --- .../api/src/app/oauth/clients.service.ts | 372 +++++++++++ .../api/src/app/oauth/grants.service.ts | 190 ++++++ .../oauth/pending-authorization.service.ts | 123 ++++ .../api/src/app/oauth/signing-key.service.ts | 237 +++++++ .../api/src/app/oauth/token-exchange.ts | 118 ++++ .../api/src/app/oauth/tokens.service.ts | 402 +++++++++++ .../test/unit/oauth/clients.service.test.ts | 518 ++++++++++++++ .../test/unit/oauth/grants.service.test.ts | 333 +++++++++ .../pending-authorization.service.test.ts | 332 +++++++++ .../unit/oauth/signing-key.service.test.ts | 309 +++++++++ .../test/unit/oauth/token-exchange.test.ts | 361 ++++++++++ .../test/unit/oauth/tokens.service.test.ts | 630 ++++++++++++++++++ 12 files changed, 3925 insertions(+) create mode 100644 packages/server/api/src/app/oauth/clients.service.ts create mode 100644 packages/server/api/src/app/oauth/grants.service.ts create mode 100644 packages/server/api/src/app/oauth/pending-authorization.service.ts create mode 100644 packages/server/api/src/app/oauth/signing-key.service.ts create mode 100644 packages/server/api/src/app/oauth/token-exchange.ts create mode 100644 packages/server/api/src/app/oauth/tokens.service.ts create mode 100644 packages/server/api/test/unit/oauth/clients.service.test.ts create mode 100644 packages/server/api/test/unit/oauth/grants.service.test.ts create mode 100644 packages/server/api/test/unit/oauth/pending-authorization.service.test.ts create mode 100644 packages/server/api/test/unit/oauth/signing-key.service.test.ts create mode 100644 packages/server/api/test/unit/oauth/token-exchange.test.ts create mode 100644 packages/server/api/test/unit/oauth/tokens.service.test.ts 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..14c863fda4 --- /dev/null +++ b/packages/server/api/src/app/oauth/clients.service.ts @@ -0,0 +1,372 @@ +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_SCOPE = 'mcp'; +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_SCOPE_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; + scope: string; + client_id_issued_at: number; +}; + +type ClientRegistrationMetadata = { + clientName: string; + redirectUris: string[]; + grantTypes: string[]; + scope: 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 parseScope(value: unknown): string { + // Left empty on purpose: the authorize endpoint applies the requested + // resource's default scope, which registration cannot know yet. + if (value === undefined) { + return ''; + } + + if (typeof value !== 'string') { + throw invalidClientMetadata('scope must be a string'); + } + + if (value.length > MAX_SCOPE_LENGTH) { + throw invalidClientMetadata( + `scope must be at most ${MAX_SCOPE_LENGTH} characters`, + ); + } + + return value; +} + +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']), + scope: parseScope(metadata['scope']), + }; +} + +/** + * 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, + scope: metadata.scope, + }; + + 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, + scope: client.scope, + 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, + scope: RS_CLIENT_SCOPE, + }); + 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..0cb45e91b9 --- /dev/null +++ b/packages/server/api/src/app/oauth/grants.service.ts @@ -0,0 +1,190 @@ +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; + projectId: string; + scope: 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(); + +function toSnapshot(grant: OAuthGrant): GrantSnapshot { + return { + id: grant.id, + userId: grant.userId, + clientId: grant.clientId, + projectId: grant.projectId, + scope: grant.scope, + status: grant.status, + }; +} + +function invalidateSnapshot(grantId: string): void { + snapshotCache.delete(grantId); +} + +export type CreateGrantParams = { + clientId: string; + userId: string; + scope: string; + resourceId: string; + projectId: 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, + projectId: params.projectId, + resourceId: params.resourceId, + scope: params.scope, + 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; + snapshotCache.set(grantId, { snapshot, fetchedAt: Date.now() }); + + return snapshot; + }, + + async getActiveGrantOrThrow(grantId: string): Promise { + const snapshot = await grantsService.getGrantSnapshot(grantId); + + if (!snapshot || 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; + } + + lastUsedWrittenAt.set(grantId, now); + await grantRepo().update( + { id: grantId }, + { lastUsedAt: new Date(now).toISOString() }, + ); + }, + + clearSnapshotCacheForTests(): void { + snapshotCache.clear(); + lastUsedWrittenAt.clear(); + }, +}; 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/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..4a256a063f --- /dev/null +++ b/packages/server/api/src/app/oauth/token-exchange.ts @@ -0,0 +1,118 @@ +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; +}; + +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'); + } + + // The exchanged token inherits the project from the subject token, so the pair + // always refer to the same project and the resource server cannot widen what it + // was given. Re-authorized here because access can be withdrawn after the + // connection was made. + const subjectProjectId = claims['project_id']; + + if (typeof subjectProjectId !== 'string') { + throw invalidGrant('token is not bound to a project'); + } + + const membership = await getOAuthProjectMembershipService().getForUser( + user, + subjectProjectId, + ); + + 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..41b9b70003 --- /dev/null +++ b/packages/server/api/src/app/oauth/tokens.service.ts @@ -0,0 +1,402 @@ +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 } 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, +): Promise { + const membership = await getOAuthProjectMembershipService().getForUser( + user, + projectId, + ); + + if (isNil(membership)) { + throw 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; + userId: string; + resource: string; + scope: 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, + userId: params.userId, + resource: params.resource, + scope: params.scope, + 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; +}; + +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); + const grant = await grantsService.create({ + clientId: codeRecord.clientId, + userId: codeRecord.userId, + scope: codeRecord.scope, + resourceId: resource.id, + projectId: await resolveDefaultProjectId(user), + }); + + const accessToken = await mintAccessToken({ + grant, + audience: resource.audience, + scope: codeRecord.scope, + projectId: grant.projectId, + ttlSeconds: oauthConfig.getAccessTokenTtlSeconds(), + }); + + const refreshToken = await issueRefreshToken({ + grantId: grant.id, + familyId: openOpsId(), + clientId: grant.clientId, + userId: grant.userId, + resource: resource.canonicalUri, + scope: codeRecord.scope, + }); + + 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); + const projectId = await authorizeProjectOrThrow(user, grant.projectId); + + 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, + userId: existingToken.userId, + resource: existingToken.resource, + scope: existingToken.scope, + }); + + 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/unit/oauth/clients.service.test.ts b/packages/server/api/test/unit/oauth/clients.service.test.ts new file mode 100644 index 0000000000..ff55350a84 --- /dev/null +++ b/packages/server/api/test/unit/oauth/clients.service.test.ts @@ -0,0 +1,518 @@ +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.length).toBe(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.scope).toBe(''); + 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); + }); + + it('persists an explicitly requested subset of grant types', async () => { + const response = await clientsService.registerClient({ + ...validMetadata(), + grant_types: ['authorization_code'], + scope: 'mcp', + }); + + expect(response.grant_types).toEqual(['authorization_code']); + expect(response.scope).toBe('mcp'); + expect(storedRow(response.client_id).grantTypes).toEqual([ + 'authorization_code', + ]); + }); + + 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); + }); + + it('rejects a scope over 128 characters', async () => { + await expect( + clientsService.registerClient({ + ...validMetadata(), + scope: 's'.repeat(129), + }), + ).rejects.toThrow('invalid_client_metadata'); + }); + }); + + 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, + scope: '', + } 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(row.scope).toBe('mcp'); + 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..43e6297cc3 --- /dev/null +++ b/packages/server/api/test/unit/oauth/grants.service.test.ts @@ -0,0 +1,333 @@ +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', + scope: 'mcp', + resourceId: 'mcp', + projectId: 'project-1', +}; + +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', + userId: 'user-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', + projectId: 'project-1', + resourceId: 'mcp', + scope: 'mcp', + status: 'active', + revokedAt: null, + }); + }); + + 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('fixes the project on the grant and never mutates it', async () => { + const grant = await grantsService.create(BASE_PARAMS); + + expect(grant.projectId).toBe('project-1'); + 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('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', + projectId: 'project-1', + }, + ); + }); + + 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/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/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..6c4b90a58a --- /dev/null +++ b/packages/server/api/test/unit/oauth/token-exchange.test.ts @@ -0,0 +1,361 @@ +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('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..23b02f0adb --- /dev/null +++ b/packages/server/api/test/unit/oauth/tokens.service.test.ts @@ -0,0 +1,630 @@ +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); + membershipService.getForUser.mockResolvedValue(MEMBERSHIP); + }); + + 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('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', + scope: 'mcp', + resourceId: 'mcp', + projectId: 'project-1', + }); + }); + + 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('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, + }); + }); + }); +}); From 5fe3eba112eed029dc7cc71dec8ba5d31e55c751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 28 Jul 2026 14:43:20 +0100 Subject: [PATCH 04/25] Authenticate OAuth tokens with positive audience enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractPrincipal dispatches on the signing algorithm, so OAuth-issued tokens are verified against the OAuth keys and accepted only when their audience is the API. A token minted for the MCP resource server therefore cannot authenticate anywhere in the API, including paths that call extractPrincipal directly such as websockets. Enforcing it at the single verification chokepoint means no route can skip it. The project a token may act on comes from its own required claim, so a credential's authority is fixed for its whole life and cannot be redirected by changing stored state. The claim selects a project rather than granting access to it: the grant, the user and the membership are re-checked on every request, so revoking access takes effect at the next request instead of at token expiry. Only a verdict about the token becomes a 401. A server-side failure propagates, because reporting it as an invalid credential makes OAuth clients discard their refresh token and re-authorize — turning a brief outage into a re-consent storm. An Authorization header now takes precedence over the session cookie: a caller presenting a token is stating which identity it wants to act as. Part of OPS-4673. --- .../context/access-token-manager.ts | 70 +++- .../authn/access-token-authn-handler.ts | 16 +- .../api/src/app/oauth/service-principal.ts | 69 ++++ .../test/unit/oauth/oauth-principal.test.ts | 371 ++++++++++++++++++ 4 files changed, 520 insertions(+), 6 deletions(-) create mode 100644 packages/server/api/src/app/oauth/service-principal.ts create mode 100644 packages/server/api/test/unit/oauth/oauth-principal.test.ts 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/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/test/unit/oauth/oauth-principal.test.ts b/packages/server/api/test/unit/oauth/oauth-principal.test.ts new file mode 100644 index 0000000000..5ecf2c7aed --- /dev/null +++ b/packages/server/api/test/unit/oauth/oauth-principal.test.ts @@ -0,0 +1,371 @@ +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(); + }); + + 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(); + }); +}); From d9e22709d94b08ba478008b42b41ac2d73fc8244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 28 Jul 2026 14:43:35 +0100 Subject: [PATCH 05/25] Add OAuth endpoints, discovery and cleanup, behind a feature flag Registration, authorization, consent decision, token, revocation and connected-app endpoints, plus RFC 8414 discovery served with a real JWKS and no fields the server cannot honour. Everything is registered only when OPS_OAUTH_ENABLED is set, so the routes do not exist by default. Authorize validation lives in its own module and returns a discriminated result, so 'never redirect to an address we have not validated' is a property of the type rather than of reviewer diligence: an unknown client or unregistered redirect_uri renders an error, and only a validated destination can receive one. Request parameters are read as unknown and checked, never cast. A parameter that is present but not a string is rejected rather than replaced by a default, since a form parser turns nested keys into objects and substituting a default would give a client something other than what it asked for. An hourly job removes expired codes, pending requests and refresh tokens, stale registrations, and connections with no usable credential left. Part of OPS-4673. --- packages/server/api/src/app/app.ts | 6 + .../api/src/app/helper/system-jobs/common.ts | 2 + .../api/src/app/oauth/authorize-validation.ts | 232 ++++++++++ .../api/src/app/oauth/oauth-cleanup-job.ts | 134 ++++++ .../api/src/app/oauth/oauth-metadata.ts | 56 +++ .../app/oauth/oauth-well-known.controller.ts | 56 +++ .../api/src/app/oauth/oauth.controller.ts | 402 ++++++++++++++++++ .../server/api/src/app/oauth/oauth.module.ts | 46 ++ .../unit/oauth/authorize-validation.test.ts | 233 ++++++++++ .../test/unit/oauth/oauth-cleanup-job.test.ts | 282 ++++++++++++ .../test/unit/oauth/oauth-metadata.test.ts | 103 +++++ 11 files changed, 1552 insertions(+) create mode 100644 packages/server/api/src/app/oauth/authorize-validation.ts create mode 100644 packages/server/api/src/app/oauth/oauth-cleanup-job.ts create mode 100644 packages/server/api/src/app/oauth/oauth-metadata.ts create mode 100644 packages/server/api/src/app/oauth/oauth-well-known.controller.ts create mode 100644 packages/server/api/src/app/oauth/oauth.controller.ts create mode 100644 packages/server/api/src/app/oauth/oauth.module.ts create mode 100644 packages/server/api/test/unit/oauth/authorize-validation.test.ts create mode 100644 packages/server/api/test/unit/oauth/oauth-cleanup-job.test.ts create mode 100644 packages/server/api/test/unit/oauth/oauth-metadata.test.ts diff --git a/packages/server/api/src/app/app.ts b/packages/server/api/src/app/app.ts index fd677eeee5..818440996b 100644 --- a/packages/server/api/src/app/app.ts +++ b/packages/server/api/src/app/app.ts @@ -53,6 +53,8 @@ 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 { 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 +227,10 @@ export const setupApp = async ( await app.register(blockVariableModule); await app.register(benchmarkModule); + if (oauthConfig.isEnabled()) { + await app.register(oauthModule); + } + app.get( '/redirect', async ( 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/oauth-cleanup-job.ts b/packages/server/api/src/app/oauth/oauth-cleanup-job.ts new file mode 100644 index 0000000000..333542d5de --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth-cleanup-job.ts @@ -0,0 +1,134 @@ +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 { + 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 * * * *'; + +export const registerOAuthCleanupJob = async (): Promise => { + systemJobHandlers.registerJobHandler( + SystemJobName.OAUTH_CLEANUP, + async (): Promise => { + 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); + } + }, + ); + + await systemJobsSchedule.upsertJob({ + job: { + name: SystemJobName.OAUTH_CLEANUP, + data: {}, + }, + schedule: { + type: 'repeated', + cron: OAUTH_CLEANUP_CRON, + }, + }); +}; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Revoked refresh tokens are kept for a while so rotation reuse detection still + * has the history it needs to recognize a replay of an old token. + */ +const REVOKED_RETENTION_DAYS = 7; + +/** 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 revokedCutoff = new Date(now - REVOKED_RETENTION_DAYS * DAY_MS); + 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, + ); + // An expired refresh token can no longer be rotated, so nothing depends on it. + const expiredRefreshTokens = await refreshTokenRepo().delete({ + expiresAt: earlierThan(nowDate), + }); + const revokedRefreshTokens = await refreshTokenRepo().delete({ + revokedAt: earlierThan(revokedCutoff), + }); + + // 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, + revokedRefreshTokens: revokedRefreshTokens.affected ?? 0, + unusedClients: unusedClients.affected ?? 0, + deadGrants: deadGrants.affected ?? 0, + }); +}; 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..89dda27748 --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth-metadata.ts @@ -0,0 +1,56 @@ +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 = new URL(oauthConfig.getIssuerUrl()).pathname.replace( + /\/+$/, + '', + ); + + return issuerPath ? [basePath, `${basePath}${issuerPath}`] : [basePath]; +} 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..d971af36af --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth.controller.ts @@ -0,0 +1,402 @@ +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 { 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 + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function noStore(reply: FastifyReply): FastifyReply { + return reply.header('Cache-Control', 'no-store').header('Pragma', 'no-cache'); +} + +function getConsentUrl(requestId: string): string { + const frontendUrl = system + .getOrThrow(SharedSystemProp.FRONTEND_URL) + .replace(/\/+$/, ''); + + return `${frontendUrl}/oauth/consent?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); + + 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'), + }), + ); + 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( + '/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', + scope: grant.scope, + resourceId: grant.resourceId, + projectId: grant.projectId, + 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, + }); +} 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..90af223a93 --- /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 { registerOAuthCleanupJob } 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 registerOAuthCleanupJob(); + + 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/test/unit/oauth/authorize-validation.test.ts b/packages/server/api/test/unit/oauth/authorize-validation.test.ts new file mode 100644 index 0000000000..d1fc52fa8c --- /dev/null +++ b/packages/server/api/test/unit/oauth/authorize-validation.test.ts @@ -0,0 +1,233 @@ +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, + scope: '', +}; + +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/oauth-cleanup-job.test.ts b/packages/server/api/test/unit/oauth/oauth-cleanup-job.test.ts new file mode 100644 index 0000000000..6cc719a7c1 --- /dev/null +++ b/packages/server/api/test/unit/oauth/oauth-cleanup-job.test.ts @@ -0,0 +1,282 @@ +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 recently revoked refresh tokens so reuse detection still has history', async () => { + refreshRows.push( + { + id: 'revoked-long-ago', + expiresAt: isoDaysAgo(-30), + revokedAt: isoDaysAgo(8), + }, + { + id: 'revoked-recently', + expiresAt: isoDaysAgo(-30), + revokedAt: isoDaysAgo(1), + }, + { + id: 'never-revoked', + expiresAt: isoDaysAgo(-30), + revokedAt: null, + }, + ); + + await oauthCleanupJobHandler(); + + expect(refreshRows.map((row) => row.id)).toEqual([ + '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, + revokedRefreshTokens: 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-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']); + }); +}); From 15c917ac5daab67d94f1aafb8c9544dbba3fd0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 28 Jul 2026 14:43:46 +0100 Subject: [PATCH 06/25] Cover OAuth database semantics with integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit tests use in-memory repositories, so the guarantees that depend on the database cannot fail there: an inverted date predicate or a non-atomic claim looks identical. These tests run the same code against a real ORM and real SQL — concurrent redemption of one code, one pending record and one refresh token; revocation cascading to a single connection; and the cleanup job's deletes. Writing them found a defect the mocks could not: cutoffs bound as ISO strings are compared textually by drivers that store a different textual format, so every row matched, including future ones. Cutoffs are now bound as Date objects. Part of OPS-4673. --- .../ce/oauth/oauth-consumption.test.ts | 421 ++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 packages/server/api/test/integration/ce/oauth/oauth-consumption.test.ts 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); + }); +}); From 5814799c6e9a69f6b4231a27ea3694f541824404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 28 Jul 2026 14:43:46 +0100 Subject: [PATCH 07/25] Document external-agent OAuth and add a flow script The design, a runbook for exercising the flow locally, and a script that walks discovery through revocation while printing the token claims at each step. There is no consent page yet, so the script stands in for it by calling the decision endpoint directly. Part of OPS-4673. --- .env.template | 18 ++ docs/oauth-design.md | 491 +++++++++++++++++++++++++++++++++++ docs/oauth-manual-testing.md | 121 +++++++++ tools/oauth-flow.sh | 177 +++++++++++++ 4 files changed, 807 insertions(+) create mode 100644 docs/oauth-design.md create mode 100644 docs/oauth-manual-testing.md create mode 100755 tools/oauth-flow.sh 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/docs/oauth-design.md b/docs/oauth-design.md new file mode 100644 index 0000000000..4d102e1890 --- /dev/null +++ b/docs/oauth-design.md @@ -0,0 +1,491 @@ +# 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 may act only on that project, so a token's authority is fixed for its whole + life and cannot be redirected by changing stored state. This edition has one + project per organization; multi-project access is an **enterprise capability** + layered on top by minting a token with a different claim (mirroring how + enterprise's `POST /v1/authentication/switch-project` already issues a new token + per project rather than mutating state). The OSS server therefore builds **no** + switching mechanism of its own — it just refuses to be the second source of + truth. +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 project state anywhere: the project is fixed on the grant at authorization | +| 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 the consent page with only an opaque `request_id`. Unauthenticated users + go through normal app login (SSO-aware) first. +5. Consent page 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. 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 the consent route 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`. + - `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 + for the project named by the subject token, so the two tokens always refer to + the same project and the resource server cannot widen what it was given. +- `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). + +### 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`, `projectId`, `resourceId`, `scope`, +`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**, set by the +authorization server at mint time and never supplied by the client. This is what +keeps a credential's meaning immutable: a token minted for one project can never +act on another, and a leaked token's blast radius is fixed. + +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. Both 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)` — the project a newly authorized connection binds to. +- `getForUser(user, projectId)` — whether the user may act there, and as what role. + +This edition answers both from the organization's single project 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 two things for free: real +per-project roles on OAuth principals, and multi-project support with no change to +the OAuth code. `projectRole` is deliberately typed as `string` here because the +role enum lives in enterprise-only shared code. + +`oauth_grant.projectId` records what the connection was authorized for — the +default used when minting, and what the connected-apps list shows. It does not +decide what a live token can do. + +### 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), `scope`, timestamps. + Usage is recorded per connection on the grant, not per client. +- `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`, `userId`, `resource`, `scope`, `expiresAt` + (**indexed**), `revokedAt`. +- `oauth_grant` — as above; FKs with `ON DELETE CASCADE`; no defaulted-to-`''` + columns (L3); `(clientId, userId)` indexed but **not** unique. + +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. + +## 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 route reads only `request_id`, fetches + `GET /v1/oauth/requests/{id}`, renders client name/scopes **from the + server**, with plain-language copy: "__ will be able to act in OpenOps as + you, across all your projects." Approve/Deny both POST the decision and navigate to + the server-returned URL only. +- **Connected apps** page under settings: lists grants (client, created, last used, + active project) with Revoke. 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/revoked refresh tokens; stale-client removal via + `NOT EXISTS` query (no full-table loads); runs 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 page, connected-apps settings page. +- **P5 — Deploy/E2E:** config, path routing, Docker, E2E matrix. + +## 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, and switching left the OSS server.** The + design originally wanted an all-projects grant with runtime switching, plus a + `project_id` parameter on token exchange. Both were wrong: multi-project access + is an enterprise capability that already issues a token per project, so an OSS + switching mechanism would compete with it, and a request-time project parameter + could only succeed by being redundant. The project is now a required token claim + (see _Project authorization_), which resolves audit finding M5 outright — there + is no mutable project state left for sessions to share — and additionally means + a token cannot have its authority changed after issuance. +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. **`oauth_client` has no usage column and signing keys have no `alg` column.** + Both were written and never read: usage is meaningful per connection (on the + grant), and the server signs with one algorithm, which the JWKS reports from a + constant. Removed rather than left as write-only fields. +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 + +- Multi-project access and project switching — an enterprise capability with its + own project-token endpoint (requirement 5). Enterprise layers it on by issuing a + token for another project; the OSS grant model needs no change to allow that. +- 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..0515c4e3b3 --- /dev/null +++ b/docs/oauth-manual-testing.md @@ -0,0 +1,121 @@ +# Testing external-agent OAuth locally + +How to exercise the OAuth 2.1 authorization server by hand. Design: +`docs/oauth-design.md` (OPS-4673). + +> **What is not built yet.** There is no consent page (that is phase P4) and no +> hosted MCP resource server (P3). A real MCP client will therefore complete +> discovery and registration, open a browser, and land on a URL the frontend does +> not route. Everything else works, and the script below stands in for the consent +> page by calling the decision endpoint directly. + +## 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. + +## 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 `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/tools/oauth-flow.sh b/tools/oauth-flow.sh new file mode 100755 index 0000000000..8886597610 --- /dev/null +++ b/tools/oauth-flow.sh @@ -0,0 +1,177 @@ +#!/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() { + 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))" "$1" +} + +json_get() { python3 -c "import json,sys;print(json.load(open(sys.argv[1]))[sys.argv[2]])" "$1" "$2"; } + +# ---------------------------------------------------------------- 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 (the UI does this; there is no consent page yet, so we drive it directly)" +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']} project={g['projectId']} 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." From 640deca426bb31bd49f0eda2f630a305f870eab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 28 Jul 2026 17:41:41 +0100 Subject: [PATCH 08/25] Make OAuth config tests independent of the local environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default-disabled assertion read the ambient environment, so it failed for any developer whose .env enables OAuth — and passed in CI only because CI has no .env. Drive both settings through the mock instead, and assert the TTL getters read their own properties rather than whatever the machine is configured with. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/test/unit/oauth/oauth-config.test.ts | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/server/api/test/unit/oauth/oauth-config.test.ts b/packages/server/api/test/unit/oauth/oauth-config.test.ts index d6020cfcf7..6ae4220a9b 100644 --- a/packages/server/api/test/unit/oauth/oauth-config.test.ts +++ b/packages/server/api/test/unit/oauth/oauth-config.test.ts @@ -1,4 +1,4 @@ -import { system } from '@openops/server-shared'; +import { AppSystemProp, system } from '@openops/server-shared'; import { oauthConfig } from '../../../src/app/oauth/oauth-config'; describe('oauthConfig', () => { @@ -32,16 +32,40 @@ describe('oauthConfig', () => { expect(oauthConfig.getMcpResourceUrl()).toBeUndefined(); }); - it('reads TTLs from the configured defaults', () => { - expect(oauthConfig.getAccessTokenTtlSeconds()).toBe(900); - expect(oauthConfig.getRefreshTokenTtlDays()).toBe(30); - expect(oauthConfig.getExchangeTokenTtlSeconds()).toBe(300); + 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); - jest.spyOn(system, 'getBoolean').mockReturnValue(true); + getBoolean.mockReturnValue(true); expect(oauthConfig.isEnabled()).toBe(true); + expect(getBoolean).toHaveBeenLastCalledWith(AppSystemProp.OAUTH_ENABLED); }); }); From d82696552e3453b4001254955ba003201d8e8bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 28 Jul 2026 17:41:56 +0100 Subject: [PATCH 09/25] Pass the tool allow-list to the MCP server as a file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rewritten MCP server takes its allow-list as a file and reads the OpenAPI document from the API itself, so writing a pre-filtered schema no longer means anything to it. Write INCLUDED_PATHS out in the shape it expects instead, which keeps this the only place the chat's exposed surface is declared, and rename the spawn variables to the ones it now reads. Operations the running API does not serve are left out. The old schema filter dropped them implicitly; doing it deliberately matters more now, because the MCP server refuses to start on an operation it cannot find — so one stale entry would cost every tool rather than just itself. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/src/app/ai/mcp/openops-tools.ts | 71 +++++++----- .../api/test/unit/ai/openops-tools.test.ts | 103 +++++++----------- 2 files changed, 85 insertions(+), 89 deletions(-) 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/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', From bc4c289012d4f4b0c8e40f2cd8f5b9d13143ca83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Tue, 28 Jul 2026 17:42:12 +0100 Subject: [PATCH 10/25] Add the OAuth consent screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An MCP client completing discovery and registration opened the browser and landed on a URL the frontend did not route, which was the last gap in the flow. Add the screen the authorize endpoint already redirects to. It names the application and the project the connection will be bound to, so the user can see whose data they are handing over — the project is resolved from the same membership lookup that binds it when the code is redeemed. The screen is authenticated but renders without the application chrome: a sidebar invites the user to wander off mid-flow, and the pending authorization expires if they do. api.post now takes headers, mirroring api.put, because the decision endpoint requires the anti-CSRF header. Co-Authored-By: Claude Opus 5 (1M context) --- docs/oauth-manual-testing.md | 54 ++++++++- .../react-ui/src/app/constants/query-keys.ts | 3 + .../navigation/layout/global-layout.tsx | 25 ++++ .../oauth/components/consent-card.tsx | 91 ++++++++++++++ .../hooks/tests/use-oauth-consent.test.tsx | 111 ++++++++++++++++++ .../features/oauth/hooks/use-oauth-consent.ts | 63 ++++++++++ .../src/app/features/oauth/lib/oauth-api.ts | 42 +++++++ .../oauth/lib/tests/oauth-api.test.ts | 46 ++++++++ packages/react-ui/src/app/lib/api.ts | 3 +- packages/react-ui/src/app/router.tsx | 12 ++ .../app/routes/oauth/consent/consent-page.tsx | 104 ++++++++++++++++ .../src/app/routes/oauth/consent/index.tsx | 1 + .../api/src/app/oauth/consent-details.ts | 46 ++++++++ .../api/src/app/oauth/oauth.controller.ts | 4 + .../test/unit/oauth/consent-details.test.ts | 75 ++++++++++++ 15 files changed, 674 insertions(+), 6 deletions(-) create mode 100644 packages/react-ui/src/app/features/oauth/components/consent-card.tsx create mode 100644 packages/react-ui/src/app/features/oauth/hooks/tests/use-oauth-consent.test.tsx create mode 100644 packages/react-ui/src/app/features/oauth/hooks/use-oauth-consent.ts create mode 100644 packages/react-ui/src/app/features/oauth/lib/oauth-api.ts create mode 100644 packages/react-ui/src/app/features/oauth/lib/tests/oauth-api.test.ts create mode 100644 packages/react-ui/src/app/routes/oauth/consent/consent-page.tsx create mode 100644 packages/react-ui/src/app/routes/oauth/consent/index.tsx create mode 100644 packages/server/api/src/app/oauth/consent-details.ts create mode 100644 packages/server/api/test/unit/oauth/consent-details.test.ts diff --git a/docs/oauth-manual-testing.md b/docs/oauth-manual-testing.md index 0515c4e3b3..8d3b0631a5 100644 --- a/docs/oauth-manual-testing.md +++ b/docs/oauth-manual-testing.md @@ -3,11 +3,12 @@ How to exercise the OAuth 2.1 authorization server by hand. Design: `docs/oauth-design.md` (OPS-4673). -> **What is not built yet.** There is no consent page (that is phase P4) and no -> hosted MCP resource server (P3). A real MCP client will therefore complete -> discovery and registration, open a browser, and land on a URL the frontend does -> not route. Everything else works, and the script below stands in for the consent -> page by calling the decision endpoint directly. +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 @@ -57,6 +58,49 @@ 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. Sign in if you are not already, and the consent screen names the +application and the project it will act in. 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 on the screen**, 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`. +- **Reloading the consent screen** after deciding shows the expired-request + message rather than granting a second authorization. The pending record is + single-use. +- **Connecting a second client** (or the same one again) produces an independent + connection: `GET /v1/oauth/grants` lists both, and revoking one leaves the + other working. + ## Things worth poking at by hand Each of these should produce a clean OAuth error, never a 500: diff --git a/packages/react-ui/src/app/constants/query-keys.ts b/packages/react-ui/src/app/constants/query-keys.ts index 2bea8e4af6..c3aaff4b81 100644 --- a/packages/react-ui/src/app/constants/query-keys.ts +++ b/packages/react-ui/src/app/constants/query-keys.ts @@ -61,6 +61,9 @@ export const QueryKeys = { // Cloud cloudUserInfo: 'cloud-user-info', + // OAuth + oauthConsentRequest: 'oauth-consent-request', + // Connections appConnections: 'app-connections', appConnection: 'app-connection', diff --git a/packages/react-ui/src/app/features/navigation/layout/global-layout.tsx b/packages/react-ui/src/app/features/navigation/layout/global-layout.tsx index 0a30da0f2d..b3b20b6f4a 100644 --- a/packages/react-ui/src/app/features/navigation/layout/global-layout.tsx +++ b/packages/react-ui/src/app/features/navigation/layout/global-layout.tsx @@ -35,6 +35,15 @@ const MINIMIZED_NAVIGATION_ROUTES = [ '/analytics', ]; +/** + * Requires a session, but renders without the application chrome. + * + * Granting an external application access is a decision about the account, so it needs + * a real signed-in user — while a sidebar and its navigation invite the user to wander + * off mid-flow, and the pending authorization expires if they do. + */ +const CHROMELESS_AUTHENTICATED_ROUTES = ['/oauth/consent']; + const UNAUTHENTICATED_ROUTES = [ '/sign-in', '/sign-up', @@ -66,6 +75,10 @@ export function GlobalLayout() { !location.pathname.startsWith('/connections'), ); + const isChromelessAuthenticatedRoute = CHROMELESS_AUTHENTICATED_ROUTES.some( + (route) => location.pathname.startsWith(route), + ); + useEffect(() => { if (previousPathname === location.pathname) { return; @@ -104,6 +117,18 @@ export function GlobalLayout() { ); } + if (isChromelessAuthenticatedRoute) { + return ( + + + + + + + + ); + } + return ( diff --git a/packages/react-ui/src/app/features/oauth/components/consent-card.tsx b/packages/react-ui/src/app/features/oauth/components/consent-card.tsx new file mode 100644 index 0000000000..9120e08024 --- /dev/null +++ b/packages/react-ui/src/app/features/oauth/components/consent-card.tsx @@ -0,0 +1,91 @@ +import { + Button, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@openops/components/ui'; +import { t } from 'i18next'; +import { OAuthConsentRequest } from '../lib/oauth-api'; + +type ConsentCardProps = { + 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'), +]; + +const DetailRow = ({ label, value }: { label: string; value: string }) => ( +
+ {label} + {value} +
+); + +const ConsentCard = ({ + request, + onApprove, + onDeny, + isDeciding, +}: ConsentCardProps) => ( + + + {t('Authorize access')} + + {request.clientName}{' '} + {t('is asking to access OpenOps as you.')} + + + + + {request.projectName && ( + + )} + +
+ {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 your OpenOps settings.', + )} +

+
+ + + + + +
+); + +ConsentCard.displayName = 'ConsentCard'; +export { ConsentCard }; 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..5d2c785b86 --- /dev/null +++ b/packages/react-ui/src/app/features/oauth/hooks/tests/use-oauth-consent.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 { 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', + scope: 'mcp', + resourceId: 'mcp', + projectId: 'proj-1', + projectName: 'Cloud Ops', +}; + +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-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..a2fc02d8e7 --- /dev/null +++ b/packages/react-ui/src/app/features/oauth/lib/oauth-api.ts @@ -0,0 +1,42 @@ +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; + scope: string; + resourceId: OAuthResourceId | null; + projectId: string | null; + projectName: string | null; +}; + +export type OAuthConsentDecision = { + /** Where to send the browser next. Always one of the client's registered URIs. */ + redirectTo: string; +}; + +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' }, + ); + +export const oauthApi = { + getConsentRequest, + decide, +}; 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..d005864a4f --- /dev/null +++ b/packages/react-ui/src/app/features/oauth/lib/tests/oauth-api.test.ts @@ -0,0 +1,46 @@ +import { api } from '@/app/lib/api'; +import { oauthApi } from '../oauth-api'; + +jest.mock('@/app/lib/api', () => ({ + api: { get: jest.fn(), post: jest.fn() }, +})); + +const mockedGet = api.get as jest.Mock; +const mockedPost = api.post as jest.Mock; + +describe('oauthApi', () => { + beforeEach(() => { + mockedGet.mockReset().mockResolvedValue({}); + mockedPost.mockReset().mockResolvedValue({ redirectTo: 'https://client' }); + }); + + 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('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..fc5da96af6 100644 --- a/packages/react-ui/src/app/router.tsx +++ b/packages/react-ui/src/app/router.tsx @@ -46,6 +46,8 @@ import GeneralPage from './routes/settings/general'; import { SignInPage } from './routes/sign-in'; import { SignUpPage } from './routes/sign-up'; +const OAuthConsentPage = lazy(() => import('@/app/routes/oauth/consent')); + const SettingsRerouter = () => { const { hash } = useLocation(); const fragmentWithoutHash = hash.slice(1).toLowerCase(); @@ -290,6 +292,16 @@ const createRoutes = ({ routes.push(...regularLoginRoutes); } + routes.push({ + path: 'oauth/consent', + element: ( + + + + ), + errorElement: , + }); + const redirectRoutes = [ { path: 'redirect', diff --git a/packages/react-ui/src/app/routes/oauth/consent/consent-page.tsx b/packages/react-ui/src/app/routes/oauth/consent/consent-page.tsx new file mode 100644 index 0000000000..4eb31a3bae --- /dev/null +++ b/packages/react-ui/src/app/routes/oauth/consent/consent-page.tsx @@ -0,0 +1,104 @@ +import { AppLogo } from '@/app/common/components/app-logo'; +import { ConsentCard } from '@/app/features/oauth/components/consent-card'; +import { useOAuthConsent } from '@/app/features/oauth/hooks/use-oauth-consent'; +import { + Alert, + AlertDescription, + AlertTitle, + LoadingSpinner, +} from '@openops/components/ui'; +import { t } from 'i18next'; +import { useSearchParams } from 'react-router-dom'; + +const REQUEST_ID_PARAM = 'request_id'; + +const ConsentLayout = ({ children }: { children: React.ReactNode }) => ( +
+
+ + {children} +
+
+); + +// Alert lays its children out in a row for the icon-plus-text case. This one is a +// heading above a paragraph, so it stacks them. +const ConsentError = ({ description }: { description: string }) => ( + + {t('This request cannot be completed')} + {description} + +); + +const ConsentPage = () => { + const [searchParams] = useSearchParams(); + const requestId = searchParams.get(REQUEST_ID_PARAM); + + const { + request, + isLoading, + loadError, + approve, + deny, + isDeciding, + decisionError, + } = useOAuthConsent(requestId); + + if (requestId === null) { + return ( + + + + ); + } + + if (isLoading) { + return ( + + + + ); + } + + // A pending request is single-use and short-lived, so a failure here is almost always + // an expired, already-answered, or reloaded request rather than something retryable. + if (loadError || !request) { + return ( + + + + ); + } + + return ( + + + {decisionError && ( + + )} + + ); +}; + +ConsentPage.displayName = 'ConsentPage'; +export { ConsentPage }; diff --git a/packages/react-ui/src/app/routes/oauth/consent/index.tsx b/packages/react-ui/src/app/routes/oauth/consent/index.tsx new file mode 100644 index 0000000000..74e1a69e7b --- /dev/null +++ b/packages/react-ui/src/app/routes/oauth/consent/index.tsx @@ -0,0 +1 @@ +export { ConsentPage as default } from './consent-page'; diff --git a/packages/server/api/src/app/oauth/consent-details.ts b/packages/server/api/src/app/oauth/consent-details.ts new file mode 100644 index 0000000000..e7708987a8 --- /dev/null +++ b/packages/server/api/src/app/oauth/consent-details.ts @@ -0,0 +1,46 @@ +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 ConsentProject = { + projectId: string; + projectName: string; +}; + +/** + * The project a new connection would be bound to, resolved for display only. + * + * The binding itself happens when the authorization code is redeemed, from the same + * membership lookup. Naming the project on the consent screen is what lets the user see + * whose data they are about to hand over. + * + * Absence is not an error here. Redemption performs the same lookup and refuses with a + * precise reason, which serves the user better than a half-rendered consent screen. + */ +export async function describeTargetProject( + userId: string, +): Promise { + const user = await userService.get({ id: userId }); + + if (isNil(user)) { + return null; + } + + const membership = await getOAuthProjectMembershipService().getDefaultForUser( + user, + ); + + if (isNil(membership)) { + return null; + } + + const project = await projectService.getOne(membership.projectId); + + return { + projectId: membership.projectId, + // Falling back to the id keeps the screen honest if the project is unreadable: + // it still names what is being granted rather than showing nothing. + projectName: project?.displayName ?? membership.projectId, + }; +} diff --git a/packages/server/api/src/app/oauth/oauth.controller.ts b/packages/server/api/src/app/oauth/oauth.controller.ts index d971af36af..d78c4e622a 100644 --- a/packages/server/api/src/app/oauth/oauth.controller.ts +++ b/packages/server/api/src/app/oauth/oauth.controller.ts @@ -17,6 +17,7 @@ import { validateAuthorizeRequest, } from './authorize-validation'; import { clientsService, TOKEN_EXCHANGE_GRANT } from './clients.service'; +import { describeTargetProject } from './consent-details'; import { grantsService } from './grants.service'; import { oauthConfig } from './oauth-config'; import { invalidRequest, unsupportedGrantType } from './oauth-errors'; @@ -192,12 +193,15 @@ export const oauthController: FastifyPluginAsyncTypebox = async (app) => { // user bases their decision on, so it must not be attacker-supplied. const client = await clientsService.getClientOrThrow(pending.clientId); const resource = resolveResource(pending.resource); + const project = await describeTargetProject(request.principal.id); return { requestId, clientName: client.clientName, scope: pending.scope, resourceId: resource?.id ?? null, + projectId: project?.projectId ?? null, + projectName: project?.projectName ?? null, }; }, ); diff --git a/packages/server/api/test/unit/oauth/consent-details.test.ts b/packages/server/api/test/unit/oauth/consent-details.test.ts new file mode 100644 index 0000000000..4cbdea54a5 --- /dev/null +++ b/packages/server/api/test/unit/oauth/consent-details.test.ts @@ -0,0 +1,75 @@ +const userGetMock = jest.fn(); +const projectGetOneMock = jest.fn(); +const getDefaultForUserMock = 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: () => ({ + getDefaultForUser: getDefaultForUserMock, + }), +})); + +import { describeTargetProject } from '../../../src/app/oauth/consent-details'; + +const USER = { id: 'user-1', organizationId: 'org-1' }; + +describe('describeTargetProject', () => { + beforeEach(() => { + jest.clearAllMocks(); + userGetMock.mockResolvedValue(USER); + getDefaultForUserMock.mockResolvedValue({ + projectId: 'proj-1', + organizationId: 'org-1', + projectRole: 'ADMIN', + }); + projectGetOneMock.mockResolvedValue({ + id: 'proj-1', + displayName: 'Cloud Ops', + }); + }); + + it('names the project the connection would be bound to', async () => { + await expect(describeTargetProject('user-1')).resolves.toEqual({ + projectId: 'proj-1', + projectName: 'Cloud Ops', + }); + }); + + it('resolves the project for the approving user, not an arbitrary one', async () => { + await describeTargetProject('user-1'); + + expect(userGetMock).toHaveBeenCalledWith({ id: 'user-1' }); + expect(getDefaultForUserMock).toHaveBeenCalledWith(USER); + }); + + it('returns nothing when the user cannot be found', async () => { + userGetMock.mockResolvedValue(null); + + await expect(describeTargetProject('user-1')).resolves.toBeNull(); + expect(getDefaultForUserMock).not.toHaveBeenCalled(); + }); + + it('returns nothing when the user has no accessible project', async () => { + getDefaultForUserMock.mockResolvedValue(null); + + await expect(describeTargetProject('user-1')).resolves.toBeNull(); + expect(projectGetOneMock).not.toHaveBeenCalled(); + }); + + it('falls back to the project id when the project is unreadable', async () => { + projectGetOneMock.mockResolvedValue(null); + + // The screen must still name what is being granted rather than showing nothing. + await expect(describeTargetProject('user-1')).resolves.toEqual({ + projectId: 'proj-1', + projectName: 'proj-1', + }); + }); +}); From 04156b7edc470b99facc0caff66528ab0364bb5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Wed, 29 Jul 2026 10:20:12 +0100 Subject: [PATCH 11/25] Move consent into a dialog on a Connected apps settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consent and connection management belong in the same place: the user decides there, and returns there to see what they granted and cut any of it off. So the authorize endpoint now redirects to Settings -> Connected apps with the request id, and the consent screen is a dialog over that page rather than a standalone route. Each row is one authorization rather than one application, because that is what is independently revocable — connecting the same agent twice produces two rows, and disconnecting one leaves the other working. Disconnecting asks first, since it takes effect immediately. Dismissing the dialog denies rather than doing nothing. The application is waiting on its redirect, and telling it no beats leaving it to time out. A CONNECTED_APPS_ENABLED flag hides the page when OAuth is off, where every route it depends on is unregistered. Co-Authored-By: Claude Opus 5 (1M context) --- docs/oauth-manual-testing.md | 25 ++-- .../components/project-settings-layout.tsx | 15 +- .../react-ui/src/app/constants/query-keys.ts | 1 + .../navigation/layout/global-layout.tsx | 25 ---- .../oauth/components/connected-apps-list.tsx | 91 ++++++++++++ .../oauth/components/consent-card.tsx | 91 ------------ .../oauth/components/consent-dialog.tsx | 105 ++++++++++++++ .../hooks/tests/use-connected-apps.test.tsx | 113 +++++++++++++++ .../oauth/hooks/use-connected-apps.ts | 54 +++++++ .../src/app/features/oauth/lib/oauth-api.ts | 25 ++++ .../oauth/lib/tests/oauth-api.test.ts | 19 ++- packages/react-ui/src/app/router.tsx | 20 ++- .../app/routes/oauth/consent/consent-page.tsx | 104 ------------- .../src/app/routes/oauth/consent/index.tsx | 1 - .../connected-apps/connected-apps-page.tsx | 137 ++++++++++++++++++ .../routes/settings/connected-apps/index.tsx | 1 + .../server/api/src/app/flags/flag.service.ts | 10 ++ .../api/src/app/oauth/oauth.controller.ts | 6 +- packages/shared/src/lib/flag/flag.ts | 1 + 19 files changed, 604 insertions(+), 240 deletions(-) create mode 100644 packages/react-ui/src/app/features/oauth/components/connected-apps-list.tsx delete mode 100644 packages/react-ui/src/app/features/oauth/components/consent-card.tsx create mode 100644 packages/react-ui/src/app/features/oauth/components/consent-dialog.tsx create mode 100644 packages/react-ui/src/app/features/oauth/hooks/tests/use-connected-apps.test.tsx create mode 100644 packages/react-ui/src/app/features/oauth/hooks/use-connected-apps.ts delete mode 100644 packages/react-ui/src/app/routes/oauth/consent/consent-page.tsx delete mode 100644 packages/react-ui/src/app/routes/oauth/consent/index.tsx create mode 100644 packages/react-ui/src/app/routes/settings/connected-apps/connected-apps-page.tsx create mode 100644 packages/react-ui/src/app/routes/settings/connected-apps/index.tsx diff --git a/docs/oauth-manual-testing.md b/docs/oauth-manual-testing.md index 8d3b0631a5..796e579741 100644 --- a/docs/oauth-manual-testing.md +++ b/docs/oauth-manual-testing.md @@ -85,21 +85,24 @@ claude mcp add --transport http openops http://localhost:3020/mcp ``` The client discovers the authorization server, registers itself, and opens your -browser. Sign in if you are not already, and the consent screen names the -application and the project it will act in. Approving sends the browser back to -the client, which redeems the code and lists the tools. +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 on the screen**, and it matches `project_id` in the +- **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`. -- **Reloading the consent screen** after deciding shows the expired-request - message rather than granting a second authorization. The pending record is - single-use. + 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: `GET /v1/oauth/grants` lists both, and revoking one leaves the - other working. + 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. ## Things worth poking at by hand @@ -134,8 +137,8 @@ curl -s -b /tmp/ck -X POST "localhost:3000/v1/oauth/requests/anything/decision" ``` To check that **connections are independent**, run `tools/oauth-flow.sh` twice -without revoking in between, then look at `GET /v1/oauth/grants`: two rows for -the same client, each revocable on its own. +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 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..875f36e9ba 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,6 +1,6 @@ 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 SidebarLayout from '@/app/common/components/sidebar-layout'; import { flagsHooks } from '@/app/common/hooks/flags-hooks'; @@ -27,6 +27,12 @@ const aiNavItem = { icon: , }; +const connectedAppsNavItem = { + title: t('Connected apps'), + href: '/settings/connected-apps', + icon: , +}; + interface SettingsLayoutProps { children: React.ReactNode; } @@ -38,10 +44,17 @@ export default function ProjectSettingsLayout({ FlagId.DARK_THEME_ENABLED, ).data; + // 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; + const sidebarNavItems = [ ...baseNavItems, ...(showAppearanceSettings ? [appearanceNavItem] : []), aiNavItem, + ...(showConnectedApps ? [connectedAppsNavItem] : []), ]; 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 c3aaff4b81..d6b09ba7f9 100644 --- a/packages/react-ui/src/app/constants/query-keys.ts +++ b/packages/react-ui/src/app/constants/query-keys.ts @@ -63,6 +63,7 @@ export const QueryKeys = { // OAuth oauthConsentRequest: 'oauth-consent-request', + connectedApps: 'connected-apps', // Connections appConnections: 'app-connections', diff --git a/packages/react-ui/src/app/features/navigation/layout/global-layout.tsx b/packages/react-ui/src/app/features/navigation/layout/global-layout.tsx index b3b20b6f4a..0a30da0f2d 100644 --- a/packages/react-ui/src/app/features/navigation/layout/global-layout.tsx +++ b/packages/react-ui/src/app/features/navigation/layout/global-layout.tsx @@ -35,15 +35,6 @@ const MINIMIZED_NAVIGATION_ROUTES = [ '/analytics', ]; -/** - * Requires a session, but renders without the application chrome. - * - * Granting an external application access is a decision about the account, so it needs - * a real signed-in user — while a sidebar and its navigation invite the user to wander - * off mid-flow, and the pending authorization expires if they do. - */ -const CHROMELESS_AUTHENTICATED_ROUTES = ['/oauth/consent']; - const UNAUTHENTICATED_ROUTES = [ '/sign-in', '/sign-up', @@ -75,10 +66,6 @@ export function GlobalLayout() { !location.pathname.startsWith('/connections'), ); - const isChromelessAuthenticatedRoute = CHROMELESS_AUTHENTICATED_ROUTES.some( - (route) => location.pathname.startsWith(route), - ); - useEffect(() => { if (previousPathname === location.pathname) { return; @@ -117,18 +104,6 @@ export function GlobalLayout() { ); } - if (isChromelessAuthenticatedRoute) { - return ( - - - - - - - - ); - } - return ( 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..660c8de3b0 --- /dev/null +++ b/packages/react-ui/src/app/features/oauth/components/connected-apps-list.tsx @@ -0,0 +1,91 @@ +import { formatUtils } from '@/app/lib/utils'; +import { Button } from '@openops/components/ui'; +import { t } from 'i18next'; +import { Plug } from 'lucide-react'; +import { ConnectedApp } from '../lib/oauth-api'; + +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; +}) => ( +
+
+ {app.clientName} + + {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-card.tsx b/packages/react-ui/src/app/features/oauth/components/consent-card.tsx deleted file mode 100644 index 9120e08024..0000000000 --- a/packages/react-ui/src/app/features/oauth/components/consent-card.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { - Button, - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@openops/components/ui'; -import { t } from 'i18next'; -import { OAuthConsentRequest } from '../lib/oauth-api'; - -type ConsentCardProps = { - 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'), -]; - -const DetailRow = ({ label, value }: { label: string; value: string }) => ( -
- {label} - {value} -
-); - -const ConsentCard = ({ - request, - onApprove, - onDeny, - isDeciding, -}: ConsentCardProps) => ( - - - {t('Authorize access')} - - {request.clientName}{' '} - {t('is asking to access OpenOps as you.')} - - - - - {request.projectName && ( - - )} - -
- {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 your OpenOps settings.', - )} -

-
- - - - - -
-); - -ConsentCard.displayName = 'ConsentCard'; -export { ConsentCard }; 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..38fe149aa1 --- /dev/null +++ b/packages/react-ui/src/app/features/oauth/components/consent-dialog.tsx @@ -0,0 +1,105 @@ +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'), +]; + +const ConsentDialog = ({ + request, + onApprove, + onDeny, + isDeciding, +}: ConsentDialogProps) => ( + { + if (!open && !isDeciding) { + onDeny(); + } + }} + > + + + {t('Authorize access')} + + {request.clientName}{' '} + {t('is asking to access OpenOps as you.')} + + + +
+ {request.projectName && ( +
+ + {t('Project')} + + + {request.projectName} + +
+ )} + +
+ + {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..cc48dc303a --- /dev/null +++ b/packages/react-ui/src/app/features/oauth/hooks/tests/use-connected-apps.test.tsx @@ -0,0 +1,113 @@ +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, + scope: 'mcp', + resourceId: 'mcp', + projectId: 'proj-1', + 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/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/lib/oauth-api.ts b/packages/react-ui/src/app/features/oauth/lib/oauth-api.ts index a2fc02d8e7..155390098b 100644 --- a/packages/react-ui/src/app/features/oauth/lib/oauth-api.ts +++ b/packages/react-ui/src/app/features/oauth/lib/oauth-api.ts @@ -22,6 +22,21 @@ export type OAuthConsentDecision = { redirectTo: string; }; +/** One authorization the user granted. Each is revocable on its own. */ +export type ConnectedApp = { + id: string; + clientName: string; + scope: string; + resourceId: OAuthResourceId | null; + projectId: string; + created: string; + lastUsedAt: string | null; +}; + +type ListConnectedAppsResponse = { + data: ConnectedApp[]; +}; + const getConsentRequest = (requestId: string): Promise => api.get(`/v1/oauth/requests/${requestId}`); @@ -36,7 +51,17 @@ const decide = ( { [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 index d005864a4f..52462ede25 100644 --- 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 @@ -2,16 +2,18 @@ import { api } from '@/app/lib/api'; import { oauthApi } from '../oauth-api'; jest.mock('@/app/lib/api', () => ({ - api: { get: jest.fn(), post: jest.fn() }, + 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 () => { @@ -33,6 +35,21 @@ describe('oauthApi', () => { ); }); + 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); diff --git a/packages/react-ui/src/app/router.tsx b/packages/react-ui/src/app/router.tsx index fc5da96af6..53de7047ba 100644 --- a/packages/react-ui/src/app/router.tsx +++ b/packages/react-ui/src/app/router.tsx @@ -46,7 +46,9 @@ import GeneralPage from './routes/settings/general'; import { SignInPage } from './routes/sign-in'; import { SignUpPage } from './routes/sign-up'; -const OAuthConsentPage = lazy(() => import('@/app/routes/oauth/consent')); +const ConnectedAppsPage = lazy( + () => import('@/app/routes/settings/connected-apps'), +); const SettingsRerouter = () => { const { hash } = useLocation(); @@ -293,11 +295,19 @@ const createRoutes = ({ } routes.push({ - path: 'oauth/consent', + path: 'settings/connected-apps', element: ( - - - + }> + + + + + + + + + + ), errorElement: , }); diff --git a/packages/react-ui/src/app/routes/oauth/consent/consent-page.tsx b/packages/react-ui/src/app/routes/oauth/consent/consent-page.tsx deleted file mode 100644 index 4eb31a3bae..0000000000 --- a/packages/react-ui/src/app/routes/oauth/consent/consent-page.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { AppLogo } from '@/app/common/components/app-logo'; -import { ConsentCard } from '@/app/features/oauth/components/consent-card'; -import { useOAuthConsent } from '@/app/features/oauth/hooks/use-oauth-consent'; -import { - Alert, - AlertDescription, - AlertTitle, - LoadingSpinner, -} from '@openops/components/ui'; -import { t } from 'i18next'; -import { useSearchParams } from 'react-router-dom'; - -const REQUEST_ID_PARAM = 'request_id'; - -const ConsentLayout = ({ children }: { children: React.ReactNode }) => ( -
-
- - {children} -
-
-); - -// Alert lays its children out in a row for the icon-plus-text case. This one is a -// heading above a paragraph, so it stacks them. -const ConsentError = ({ description }: { description: string }) => ( - - {t('This request cannot be completed')} - {description} - -); - -const ConsentPage = () => { - const [searchParams] = useSearchParams(); - const requestId = searchParams.get(REQUEST_ID_PARAM); - - const { - request, - isLoading, - loadError, - approve, - deny, - isDeciding, - decisionError, - } = useOAuthConsent(requestId); - - if (requestId === null) { - return ( - - - - ); - } - - if (isLoading) { - return ( - - - - ); - } - - // A pending request is single-use and short-lived, so a failure here is almost always - // an expired, already-answered, or reloaded request rather than something retryable. - if (loadError || !request) { - return ( - - - - ); - } - - return ( - - - {decisionError && ( - - )} - - ); -}; - -ConsentPage.displayName = 'ConsentPage'; -export { ConsentPage }; diff --git a/packages/react-ui/src/app/routes/oauth/consent/index.tsx b/packages/react-ui/src/app/routes/oauth/consent/index.tsx deleted file mode 100644 index 74e1a69e7b..0000000000 --- a/packages/react-ui/src/app/routes/oauth/consent/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ConsentPage as default } from './consent-page'; 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..c927d1afe9 --- /dev/null +++ b/packages/react-ui/src/app/routes/settings/connected-apps/connected-apps-page.tsx @@ -0,0 +1,137 @@ +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, + Separator, +} 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 ( +
+
+
+

{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')} + 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/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/oauth/oauth.controller.ts b/packages/server/api/src/app/oauth/oauth.controller.ts index d78c4e622a..f869716d58 100644 --- a/packages/server/api/src/app/oauth/oauth.controller.ts +++ b/packages/server/api/src/app/oauth/oauth.controller.ts @@ -91,12 +91,16 @@ 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 = system .getOrThrow(SharedSystemProp.FRONTEND_URL) .replace(/\/+$/, ''); - return `${frontendUrl}/oauth/consent?request_id=${encodeURIComponent( + return `${frontendUrl}/settings/connected-apps?request_id=${encodeURIComponent( requestId, )}`; } 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', } From c3480559664cbd1bd6b3e3c4531e7a82b7bf2348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Wed, 29 Jul 2026 10:46:27 +0100 Subject: [PATCH 12/25] Record why the OAuth principal must stay SERVICE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project binding rests entirely on the principal type. ProjectAuthzHandler rejects a request naming a project other than the principal's, but enterprise's /switch-project is on that handler's ignore list — minting a token for another project is its purpose. What keeps an OAuth connection out of it is that the route allows only PrincipalType.USER while an OAuth token yields SERVICE. That invariant was asserted but unexplained, which is how it gets changed by someone being helpful. Say what it protects, at the assertion and in the design doc, along with the two changes that would quietly make project_id decorative. Co-Authored-By: Claude Opus 5 (1M context) --- docs/oauth-design.md | 15 +++++++++++++++ .../api/test/unit/oauth/oauth-principal.test.ts | 11 +++++++++++ 2 files changed, 26 insertions(+) diff --git a/docs/oauth-design.md b/docs/oauth-design.md index 4d102e1890..e1cdd67138 100644 --- a/docs/oauth-design.md +++ b/docs/oauth-design.md @@ -314,6 +314,21 @@ conditional `UPDATE … WHERE … AND consumedAt IS NULL` branching on affected cookie-based flows. - Route policies: OAuth-derived `SERVICE` principals flow through the existing ~40 `[USER, SERVICE]` route policies unchanged. +- **`SERVICE`, never `USER` — this is what confines a connection to its project.** + `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`. Two consequences for whoever + merges this into enterprise: do not add `SERVICE` to `/switch-project`'s + `allowedPrincipals`, and do not build the OAuth principal as `USER`. Either change + makes the `project_id` claim decorative. The invariant is 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/`) diff --git a/packages/server/api/test/unit/oauth/oauth-principal.test.ts b/packages/server/api/test/unit/oauth/oauth-principal.test.ts index 5ecf2c7aed..8a4591a22d 100644 --- a/packages/server/api/test/unit/oauth/oauth-principal.test.ts +++ b/packages/server/api/test/unit/oauth/oauth-principal.test.ts @@ -133,6 +133,17 @@ describe('extractPrincipal with OAuth tokens', () => { 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(), From 4539448e191ee880df2acf79ce934cae3945b27e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Wed, 29 Jul 2026 11:51:41 +0100 Subject: [PATCH 13/25] Let a connection switch project, bounded by membership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent should be able to move between projects the way its user can in the browser. The project claim was already a selector rather than a grant of authority — every request re-authorizes it — so this opens the missing half: a client may name where it wants to act when getting a token. Three ways in. refresh_token with project_id moves a direct API client. The token-exchange grant with project_id moves a resource server on an agent's behalf, which is the path an MCP client takes since it cannot mint tokens itself. And GET /v1/oauth/projects tells a connection where it may go and where it is now, allowing SERVICE so the connection itself can ask. Membership is the bound, re-read on every mint and again on every request, so a switch can never reach further than the user could. A project they are not a member of is invalid_target, and on the refresh path that refusal lands before the token is consumed — asking for the wrong project must not cost a working credential. listForUser joins the seam, and deliberately answers with the same rule as getForUser: listing less than the token endpoint permits would show a client one destination while allowing another it was never told about. The consent screen now says a connection can act in any project the user has access to, because it can. Showing only the starting project implied a fence that is not there. Co-Authored-By: Claude Opus 5 (1M context) --- docs/oauth-design.md | 75 +++++++++++------- docs/oauth-manual-testing.md | 29 +++++++ .../oauth/components/consent-dialog.tsx | 6 +- .../api/src/app/oauth/consent-details.ts | 30 +++++++ .../api/src/app/oauth/oauth.controller.ts | 34 +++++++- .../api/src/app/oauth/project-membership.ts | 32 +++++++- .../api/src/app/oauth/token-exchange.ts | 23 ++++-- .../api/src/app/oauth/tokens.service.ts | 26 ++++++- .../test/unit/oauth/consent-details.test.ts | 56 ++++++++++++- .../test/unit/oauth/token-exchange.test.ts | 48 ++++++++++++ .../test/unit/oauth/tokens.service.test.ts | 78 +++++++++++++++++++ 11 files changed, 398 insertions(+), 39 deletions(-) diff --git a/docs/oauth-design.md b/docs/oauth-design.md index e1cdd67138..29906cab12 100644 --- a/docs/oauth-design.md +++ b/docs/oauth-design.md @@ -246,34 +246,57 @@ worst case, and other connections are unaffected. ### Project authorization -The project a token may act on is a **required `project_id` claim**, set by the -authorization server at mint time and never supplied by the client. This is what -keeps a credential's meaning immutable: a token minted for one project can never -act on another, and a leaked token's blast radius is fixed. +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. Both questions the server -asks about projects sit behind one factory, +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)` — the project a newly authorized connection binds to. +- `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. -This edition answers both from the organization's single project with role -`ADMIN`, matching the session login path. An edition with real project membership -maps them onto its own lookups (in the enterprise fork, +`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 two things for free: real -per-project roles on OAuth principals, and multi-project support with no change to -the OAuth code. `projectRole` is deliberately typed as `string` here because the -role enum lives in enterprise-only shared code. +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. -`oauth_grant.projectId` records what the connection was authorized for — the -default used when minting, and what the connected-apps list shows. It does not -decide what a live token can do. +`oauth_grant.projectId` records where the connection started — the default used when +minting if no project is asked for. It does not decide what a live token can do. ### Data model (new tables) @@ -314,15 +337,15 @@ conditional `UPDATE … WHERE … AND consumedAt IS NULL` branching on affected cookie-based flows. - Route policies: OAuth-derived `SERVICE` principals flow through the existing ~40 `[USER, SERVICE]` route policies unchanged. -- **`SERVICE`, never `USER` — this is what confines a connection to its project.** - `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`. Two consequences for whoever - merges this into enterprise: do not add `SERVICE` to `/switch-project`'s - `allowedPrincipals`, and do not build the OAuth principal as `USER`. Either change - makes the `project_id` claim decorative. The invariant is pinned by +- **`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 diff --git a/docs/oauth-manual-testing.md b/docs/oauth-manual-testing.md index 796e579741..bb897dbe3e 100644 --- a/docs/oauth-manual-testing.md +++ b/docs/oauth-manual-testing.md @@ -104,6 +104,35 @@ Worth confirming while you are here: - **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: 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 index 38fe149aa1..10d341bdd4 100644 --- a/packages/react-ui/src/app/features/oauth/components/consent-dialog.tsx +++ b/packages/react-ui/src/app/features/oauth/components/consent-dialog.tsx @@ -29,6 +29,10 @@ const describeAccess = (): string[] => [ t('View your workflows, runs, and connections'), t('Create and change workflows on your behalf'), t('Run workflows and retry runs'), + // Said out loud because it is the widest thing being granted. The project below is + // where the connection starts, not a fence around it: the application can move to + // any project this user can reach, exactly as they could in the browser. + t('Act in any project you have access to, not only the one below'), ]; const ConsentDialog = ({ @@ -61,7 +65,7 @@ const ConsentDialog = ({ {request.projectName && (
- {t('Project')} + {t('Starting in')} {request.projectName} diff --git a/packages/server/api/src/app/oauth/consent-details.ts b/packages/server/api/src/app/oauth/consent-details.ts index e7708987a8..269b4cbfc3 100644 --- a/packages/server/api/src/app/oauth/consent-details.ts +++ b/packages/server/api/src/app/oauth/consent-details.ts @@ -8,6 +8,36 @@ export type ConsentProject = { 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, + })); +} + /** * The project a new connection would be bound to, resolved for display only. * diff --git a/packages/server/api/src/app/oauth/oauth.controller.ts b/packages/server/api/src/app/oauth/oauth.controller.ts index f869716d58..2b28ea0f14 100644 --- a/packages/server/api/src/app/oauth/oauth.controller.ts +++ b/packages/server/api/src/app/oauth/oauth.controller.ts @@ -17,7 +17,10 @@ import { validateAuthorizeRequest, } from './authorize-validation'; import { clientsService, TOKEN_EXCHANGE_GRANT } from './clients.service'; -import { describeTargetProject } from './consent-details'; +import { + describeTargetProject, + listAvailableProjects, +} from './consent-details'; import { grantsService } from './grants.service'; import { oauthConfig } from './oauth-config'; import { invalidRequest, unsupportedGrantType } from './oauth-errors'; @@ -291,6 +294,7 @@ export const oauthController: FastifyPluginAsyncTypebox = async (app) => { authorizationHeader: request.headers.authorization, subjectToken: requireParam(body, 'subject_token'), subjectTokenType: optionalParam(body, 'subject_token_type'), + requestedProjectId: optionalParam(body, 'project_id'), }), ); default: @@ -328,6 +332,33 @@ export const oauthController: FastifyPluginAsyncTypebox = async (app) => { }, ); + 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', { @@ -406,5 +437,6 @@ async function handleRefreshTokenGrant( return tokensService.rotateRefreshToken({ refreshToken: requireParam(body, 'refresh_token'), clientId, + requestedProjectId: optionalParam(body, 'project_id'), }); } diff --git a/packages/server/api/src/app/oauth/project-membership.ts b/packages/server/api/src/app/oauth/project-membership.ts index 162bfc787d..dc1cf71b44 100644 --- a/packages/server/api/src/app/oauth/project-membership.ts +++ b/packages/server/api/src/app/oauth/project-membership.ts @@ -15,12 +15,12 @@ export type OAuthProjectMembership = { }; /** - * The two questions the OAuth server asks about projects. Kept behind a factory + * 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 = { - /** Which project a newly authorized connection is bound to. */ + /** Where a newly authorized connection starts. */ getDefaultForUser(user: User): Promise; /** * Whether this user may act in this project, and as what. Called on every @@ -31,6 +31,12 @@ export type OAuthProjectMembershipService = { 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 @@ -68,4 +74,26 @@ export const oauthProjectMembershipService: OAuthProjectMembershipService = { 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/token-exchange.ts b/packages/server/api/src/app/oauth/token-exchange.ts index 4a256a063f..e3aafdadc2 100644 --- a/packages/server/api/src/app/oauth/token-exchange.ts +++ b/packages/server/api/src/app/oauth/token-exchange.ts @@ -15,6 +15,8 @@ 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 = { @@ -81,19 +83,30 @@ export async function exchangeToken( throw invalidGrant('the user for this authorization is no longer active'); } - // The exchanged token inherits the project from the subject token, so the pair - // always refer to the same project and the resource server cannot widen what it - // was given. Re-authorized here because access can be withdrawn after the - // connection was made. 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, - subjectProjectId, + targetProjectId, ); if (isNil(membership)) { diff --git a/packages/server/api/src/app/oauth/tokens.service.ts b/packages/server/api/src/app/oauth/tokens.service.ts index 41b9b70003..113ec6f188 100644 --- a/packages/server/api/src/app/oauth/tokens.service.ts +++ b/packages/server/api/src/app/oauth/tokens.service.ts @@ -6,7 +6,7 @@ import { userService } from '../user/user-service'; import { grantsService } from './grants.service'; import { oauthConfig } from './oauth-config'; import { generateOpaqueToken, sha256Hex } from './oauth-crypto'; -import { invalidGrant } from './oauth-errors'; +import { invalidGrant, invalidTarget } from './oauth-errors'; import { OAuthAuthorizationCode, OAuthGrant, @@ -74,6 +74,7 @@ async function resolveDefaultProjectId(user: User): Promise { async function authorizeProjectOrThrow( user: User, projectId: string, + wasRequested = false, ): Promise { const membership = await getOAuthProjectMembershipService().getForUser( user, @@ -81,7 +82,13 @@ async function authorizeProjectOrThrow( ); if (isNil(membership)) { - throw invalidGrant('the project for this authorization is not accessible'); + // 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; @@ -150,6 +157,12 @@ export type RedeemAuthorizationCodeParams = { 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 = { @@ -291,7 +304,14 @@ export const tokensService = { existingToken.grantId, ); const user = await loadActiveUserOrThrow(grant.userId); - const projectId = await authorizeProjectOrThrow(user, grant.projectId); + // A refresh is where a connection changes project: the client names where it wants + // to be, and membership decides whether it may. Nothing is stored, so the switch + // lasts exactly as long as the token it produced. + const projectId = await authorizeProjectOrThrow( + user, + params.requestedProjectId ?? grant.projectId, + params.requestedProjectId !== undefined, + ); const resource = resolveResource(existingToken.resource); diff --git a/packages/server/api/test/unit/oauth/consent-details.test.ts b/packages/server/api/test/unit/oauth/consent-details.test.ts index 4cbdea54a5..c811d876e7 100644 --- a/packages/server/api/test/unit/oauth/consent-details.test.ts +++ b/packages/server/api/test/unit/oauth/consent-details.test.ts @@ -1,6 +1,7 @@ const userGetMock = jest.fn(); const projectGetOneMock = jest.fn(); const getDefaultForUserMock = jest.fn(); +const listForUserMock = jest.fn(); jest.mock('../../../src/app/user/user-service', () => ({ userService: { get: userGetMock }, @@ -13,10 +14,14 @@ jest.mock('../../../src/app/project/project-service', () => ({ jest.mock('../../../src/app/oauth/project-membership-factory', () => ({ getOAuthProjectMembershipService: () => ({ getDefaultForUser: getDefaultForUserMock, + listForUser: listForUserMock, }), })); -import { describeTargetProject } from '../../../src/app/oauth/consent-details'; +import { + describeTargetProject, + listAvailableProjects, +} from '../../../src/app/oauth/consent-details'; const USER = { id: 'user-1', organizationId: 'org-1' }; @@ -73,3 +78,52 @@ describe('describeTargetProject', () => { }); }); }); + +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/token-exchange.test.ts b/packages/server/api/test/unit/oauth/token-exchange.test.ts index 6c4b90a58a..1538077c9b 100644 --- a/packages/server/api/test/unit/oauth/token-exchange.test.ts +++ b/packages/server/api/test/unit/oauth/token-exchange.test.ts @@ -333,6 +333,54 @@ describe('exchangeToken', () => { ); }); + 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', diff --git a/packages/server/api/test/unit/oauth/tokens.service.test.ts b/packages/server/api/test/unit/oauth/tokens.service.test.ts index 23b02f0adb..4ce247df17 100644 --- a/packages/server/api/test/unit/oauth/tokens.service.test.ts +++ b/packages/server/api/test/unit/oauth/tokens.service.test.ts @@ -473,6 +473,84 @@ describe('tokensService', () => { ); }); + 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('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({ From 244827b77698958f7be3e828c09621f25a6c7d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Wed, 29 Jul 2026 12:11:53 +0100 Subject: [PATCH 14/25] Drop the project row from the consent screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Naming one project made sense while a connection was confined to it. Now that it can act wherever its user can, the row states a fact and the bullet beneath immediately says that fact is not a limit — and a reader scanning a consent screen will take a named project as a limit. Removing it leaves one honest line about projects instead of two that argue. With nothing displaying them, the projectId and projectName fields on the consent response and the describeTargetProject helper behind them are dead, so they go too. Redemption already refuses with a precise reason when a user has no reachable project, which was the only enforcement that helper contributed. consent-details.ts is left holding just the project listing, so it is renamed for what it does. Co-Authored-By: Claude Opus 5 (1M context) --- .../oauth/components/consent-dialog.tsx | 19 +---- .../hooks/tests/use-oauth-consent.test.tsx | 2 - .../src/app/features/oauth/lib/oauth-api.ts | 2 - .../server/api/cache/codes/package-lock.json | 52 +++++++++++++ packages/server/api/cache/codes/package.json | 18 +++++ .../api/src/app/oauth/available-projects.ts | 39 ++++++++++ .../api/src/app/oauth/consent-details.ts | 76 ------------------- .../api/src/app/oauth/oauth.controller.ts | 10 +-- ...ils.test.ts => available-projects.test.ts} | 61 +-------------- 9 files changed, 117 insertions(+), 162 deletions(-) create mode 100644 packages/server/api/cache/codes/package-lock.json create mode 100644 packages/server/api/cache/codes/package.json create mode 100644 packages/server/api/src/app/oauth/available-projects.ts delete mode 100644 packages/server/api/src/app/oauth/consent-details.ts rename packages/server/api/test/unit/oauth/{consent-details.test.ts => available-projects.test.ts} (54%) 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 index 10d341bdd4..501ccc317f 100644 --- a/packages/react-ui/src/app/features/oauth/components/consent-dialog.tsx +++ b/packages/react-ui/src/app/features/oauth/components/consent-dialog.tsx @@ -29,10 +29,10 @@ const describeAccess = (): string[] => [ t('View your workflows, runs, and connections'), t('Create and change workflows on your behalf'), t('Run workflows and retry runs'), - // Said out loud because it is the widest thing being granted. The project below is - // where the connection starts, not a fence around it: the application can move to - // any project this user can reach, exactly as they could in the browser. - t('Act in any project you have access to, not only the one below'), + // 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 = ({ @@ -62,17 +62,6 @@ const ConsentDialog = ({
- {request.projectName && ( -
- - {t('Starting in')} - - - {request.projectName} - -
- )} -
{t('It will be able to:')} 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 index 5d2c785b86..a9a949fbb4 100644 --- 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 @@ -16,8 +16,6 @@ const REQUEST: OAuthConsentRequest = { clientName: 'Claude Code', scope: 'mcp', resourceId: 'mcp', - projectId: 'proj-1', - projectName: 'Cloud Ops', }; const assign = jest.fn(); 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 index 155390098b..18f45d593c 100644 --- a/packages/react-ui/src/app/features/oauth/lib/oauth-api.ts +++ b/packages/react-ui/src/app/features/oauth/lib/oauth-api.ts @@ -13,8 +13,6 @@ export type OAuthConsentRequest = { clientName: string; scope: string; resourceId: OAuthResourceId | null; - projectId: string | null; - projectName: string | null; }; export type OAuthConsentDecision = { diff --git a/packages/server/api/cache/codes/package-lock.json b/packages/server/api/cache/codes/package-lock.json new file mode 100644 index 0000000000..aa3a1c5083 --- /dev/null +++ b/packages/server/api/cache/codes/package-lock.json @@ -0,0 +1,52 @@ +{ + "name": "codes", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codes", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@tsconfig/node20": "20.1.4", + "@types/node": "20.14.8", + "typescript": "5.6.3" + } + }, + "node_modules/@tsconfig/node20": { + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.4.tgz", + "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.14.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.8.tgz", + "integrity": "sha512-DO+2/jZinXfROG7j7WKFn/3C6nFwxy2lLpgLjEXJz+0XKphZlTLJ14mo8Vfg8X5BWN6XjyESXq+LcYdT7tR3bA==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + } + } +} diff --git a/packages/server/api/cache/codes/package.json b/packages/server/api/cache/codes/package.json new file mode 100644 index 0000000000..a81252c932 --- /dev/null +++ b/packages/server/api/cache/codes/package.json @@ -0,0 +1,18 @@ +{ + "name": "codes", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "@tsconfig/node20": "20.1.4", + "@types/node": "20.14.8", + "typescript": "5.6.3" + } +} 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/consent-details.ts b/packages/server/api/src/app/oauth/consent-details.ts deleted file mode 100644 index 269b4cbfc3..0000000000 --- a/packages/server/api/src/app/oauth/consent-details.ts +++ /dev/null @@ -1,76 +0,0 @@ -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 ConsentProject = { - 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, - })); -} - -/** - * The project a new connection would be bound to, resolved for display only. - * - * The binding itself happens when the authorization code is redeemed, from the same - * membership lookup. Naming the project on the consent screen is what lets the user see - * whose data they are about to hand over. - * - * Absence is not an error here. Redemption performs the same lookup and refuses with a - * precise reason, which serves the user better than a half-rendered consent screen. - */ -export async function describeTargetProject( - userId: string, -): Promise { - const user = await userService.get({ id: userId }); - - if (isNil(user)) { - return null; - } - - const membership = await getOAuthProjectMembershipService().getDefaultForUser( - user, - ); - - if (isNil(membership)) { - return null; - } - - const project = await projectService.getOne(membership.projectId); - - return { - projectId: membership.projectId, - // Falling back to the id keeps the screen honest if the project is unreadable: - // it still names what is being granted rather than showing nothing. - projectName: project?.displayName ?? membership.projectId, - }; -} diff --git a/packages/server/api/src/app/oauth/oauth.controller.ts b/packages/server/api/src/app/oauth/oauth.controller.ts index 2b28ea0f14..9e113e1c88 100644 --- a/packages/server/api/src/app/oauth/oauth.controller.ts +++ b/packages/server/api/src/app/oauth/oauth.controller.ts @@ -16,11 +16,8 @@ import { requireParam, validateAuthorizeRequest, } from './authorize-validation'; +import { listAvailableProjects } from './available-projects'; import { clientsService, TOKEN_EXCHANGE_GRANT } from './clients.service'; -import { - describeTargetProject, - listAvailableProjects, -} from './consent-details'; import { grantsService } from './grants.service'; import { oauthConfig } from './oauth-config'; import { invalidRequest, unsupportedGrantType } from './oauth-errors'; @@ -200,15 +197,14 @@ export const oauthController: FastifyPluginAsyncTypebox = async (app) => { // user bases their decision on, so it must not be attacker-supplied. const client = await clientsService.getClientOrThrow(pending.clientId); const resource = resolveResource(pending.resource); - const project = await describeTargetProject(request.principal.id); + // 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, - projectId: project?.projectId ?? null, - projectName: project?.projectName ?? null, }; }, ); diff --git a/packages/server/api/test/unit/oauth/consent-details.test.ts b/packages/server/api/test/unit/oauth/available-projects.test.ts similarity index 54% rename from packages/server/api/test/unit/oauth/consent-details.test.ts rename to packages/server/api/test/unit/oauth/available-projects.test.ts index c811d876e7..579b2cb08d 100644 --- a/packages/server/api/test/unit/oauth/consent-details.test.ts +++ b/packages/server/api/test/unit/oauth/available-projects.test.ts @@ -1,6 +1,5 @@ const userGetMock = jest.fn(); const projectGetOneMock = jest.fn(); -const getDefaultForUserMock = jest.fn(); const listForUserMock = jest.fn(); jest.mock('../../../src/app/user/user-service', () => ({ @@ -13,72 +12,14 @@ jest.mock('../../../src/app/project/project-service', () => ({ jest.mock('../../../src/app/oauth/project-membership-factory', () => ({ getOAuthProjectMembershipService: () => ({ - getDefaultForUser: getDefaultForUserMock, listForUser: listForUserMock, }), })); -import { - describeTargetProject, - listAvailableProjects, -} from '../../../src/app/oauth/consent-details'; +import { listAvailableProjects } from '../../../src/app/oauth/available-projects'; const USER = { id: 'user-1', organizationId: 'org-1' }; -describe('describeTargetProject', () => { - beforeEach(() => { - jest.clearAllMocks(); - userGetMock.mockResolvedValue(USER); - getDefaultForUserMock.mockResolvedValue({ - projectId: 'proj-1', - organizationId: 'org-1', - projectRole: 'ADMIN', - }); - projectGetOneMock.mockResolvedValue({ - id: 'proj-1', - displayName: 'Cloud Ops', - }); - }); - - it('names the project the connection would be bound to', async () => { - await expect(describeTargetProject('user-1')).resolves.toEqual({ - projectId: 'proj-1', - projectName: 'Cloud Ops', - }); - }); - - it('resolves the project for the approving user, not an arbitrary one', async () => { - await describeTargetProject('user-1'); - - expect(userGetMock).toHaveBeenCalledWith({ id: 'user-1' }); - expect(getDefaultForUserMock).toHaveBeenCalledWith(USER); - }); - - it('returns nothing when the user cannot be found', async () => { - userGetMock.mockResolvedValue(null); - - await expect(describeTargetProject('user-1')).resolves.toBeNull(); - expect(getDefaultForUserMock).not.toHaveBeenCalled(); - }); - - it('returns nothing when the user has no accessible project', async () => { - getDefaultForUserMock.mockResolvedValue(null); - - await expect(describeTargetProject('user-1')).resolves.toBeNull(); - expect(projectGetOneMock).not.toHaveBeenCalled(); - }); - - it('falls back to the project id when the project is unreadable', async () => { - projectGetOneMock.mockResolvedValue(null); - - // The screen must still name what is being granted rather than showing nothing. - await expect(describeTargetProject('user-1')).resolves.toEqual({ - projectId: 'proj-1', - projectName: 'proj-1', - }); - }); -}); - describe('listAvailableProjects', () => { beforeEach(() => { jest.clearAllMocks(); From 9df350afacda2a3f0d32a06b7c9519b76072e698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Wed, 29 Jul 2026 12:42:52 +0100 Subject: [PATCH 15/25] Stop tracking the local engine code cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine resolves its code cache with path.resolve('cache','codes'), relative to the working directory, so running a server from inside packages/server/api writes one there. Two files from it were committed by a broad git add — node_modules was already ignored, which is why only those two slipped through. The existing /cache rule is anchored to the repository root and never covered it. The new pattern needs the **/ prefix for the same reason: a pattern with an interior slash is anchored to the directory holding .gitignore. Scoped to cache/codes/ rather than any directory named cache, because packages/server/shared has real source in one. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 ++ .../server/api/cache/codes/package-lock.json | 52 ------------------- packages/server/api/cache/codes/package.json | 18 ------- 3 files changed, 4 insertions(+), 70 deletions(-) delete mode 100644 packages/server/api/cache/codes/package-lock.json delete mode 100644 packages/server/api/cache/codes/package.json 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/packages/server/api/cache/codes/package-lock.json b/packages/server/api/cache/codes/package-lock.json deleted file mode 100644 index aa3a1c5083..0000000000 --- a/packages/server/api/cache/codes/package-lock.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "codes", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "codes", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "@tsconfig/node20": "20.1.4", - "@types/node": "20.14.8", - "typescript": "5.6.3" - } - }, - "node_modules/@tsconfig/node20": { - "version": "20.1.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.4.tgz", - "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "20.14.8", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.8.tgz", - "integrity": "sha512-DO+2/jZinXfROG7j7WKFn/3C6nFwxy2lLpgLjEXJz+0XKphZlTLJ14mo8Vfg8X5BWN6XjyESXq+LcYdT7tR3bA==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - } - } -} diff --git a/packages/server/api/cache/codes/package.json b/packages/server/api/cache/codes/package.json deleted file mode 100644 index a81252c932..0000000000 --- a/packages/server/api/cache/codes/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "codes", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "keywords": [], - "author": "", - "license": "ISC", - "type": "commonjs", - "dependencies": { - "@tsconfig/node20": "20.1.4", - "@types/node": "20.14.8", - "typescript": "5.6.3" - } -} From 9638167556b213c89829e503f89a35074fa12cf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Wed, 29 Jul 2026 12:54:14 +0100 Subject: [PATCH 16/25] Bring the design doc back in line with what shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several passages still described the state before the last few changes, and a design doc that contradicts the code is worse than none — a reader trusts it. The largest was decision 5 and deviation 5, which between them said a token may act only on its project and that the OSS server deliberately builds no switching. Both describe the intermediate position, not the shipped one. Deviation 5 now records the reversal and why: /switch-project is unreachable for an OAuth connection, so declining to build a mechanism removed the capability rather than deferring it to enterprise. Also corrected: the token-exchange entry still promised the resource server cannot widen what it was given; refresh did not mention project_id; /oauth/projects was missing from the endpoint list; the consent UI section described a standalone route; audit row M5 attributed the fix to a fixed project rather than to there being no mutable state; the phase list read as outstanding work; and project switching sat under Out of scope. The M5 note now warns that the one unbuilt piece — the resource server's switch tool — is exactly where that finding came from, since a process-local map is the obvious implementation and the wrong one. Co-Authored-By: Claude Opus 5 (1M context) --- docs/oauth-design.md | 126 ++++++++++++++++++++++++++++--------------- tools/oauth-flow.sh | 2 +- 2 files changed, 85 insertions(+), 43 deletions(-) diff --git a/docs/oauth-design.md b/docs/oauth-design.md index 29906cab12..2119f75134 100644 --- a/docs/oauth-design.md +++ b/docs/oauth-design.md @@ -31,15 +31,16 @@ when SSO is enabled and password login is disabled (OPS-4673). 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 may act only on that project, so a token's authority is fixed for its whole - life and cannot be redirected by changing stored state. This edition has one - project per organization; multi-project access is an **enterprise capability** - layered on top by minting a token with a different claim (mirroring how - enterprise's `POST /v1/authentication/switch-project` already issues a new token - per project rather than mutating state). The OSS server therefore builds **no** - switching mechanism of its own — it just refuses to be the second source of - truth. +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. @@ -65,7 +66,7 @@ when SSO is enabled and password login is disabled (OPS-4673). | 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 project state anywhere: the project is fixed on the grant at authorization | +| 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) | @@ -97,12 +98,13 @@ when SSO is enabled and password login is disabled (OPS-4673). 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 the consent page with only an opaque `request_id`. Unauthenticated users - go through normal app login (SSO-aware) first. -5. Consent page 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. Both redirects carry `state` - and `iss` (RFC 9207). + 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 @@ -188,8 +190,8 @@ none` (public, PKCE-only). 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 the consent route with only - `?request_id=`. + (~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 @@ -207,19 +209,26 @@ none` (public, PKCE-only). 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`. + 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 - for the project named by the subject token, so the two tokens always refer to - the same project and the resource server cannot widen what it was given. + 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 @@ -377,13 +386,23 @@ URI`) wrapped in `RemoteAuthProvider` → serves RFC 9728 PRM (root and path-awa ## Consent UI (react-ui) -- Consent route reads only `request_id`, fetches - `GET /v1/oauth/requests/{id}`, renders client name/scopes **from the - server**, with plain-language copy: "__ will be able to act in OpenOps as - you, across all your projects." Approve/Deny both POST the decision and navigate to - the server-returned URL only. -- **Connected apps** page under settings: lists grants (client, created, last used, - active project) with Revoke. All strings i18n; `react` skill patterns. +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 @@ -444,9 +463,22 @@ Every audit finding becomes a regression test. Highlights: suite. - **P3 — Python RS:** http transport, JWKS verifier, PRM + challenge middleware, exchange client + cache + fail-closed, project-switch tool. -- **P4 — UI:** consent page, connected-apps settings page. +- **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 @@ -488,15 +520,24 @@ document originally specified. 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, and switching left the OSS server.** The - design originally wanted an all-projects grant with runtime switching, plus a - `project_id` parameter on token exchange. Both were wrong: multi-project access - is an enterprise capability that already issues a token per project, so an OSS - switching mechanism would compete with it, and a request-time project parameter - could only succeed by being redundant. The project is now a required token claim - (see _Project authorization_), which resolves audit finding M5 outright — there - is no mutable project state left for sessions to share — and additionally means - a token cannot have its authority changed after issuance. +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. @@ -521,9 +562,10 @@ document originally specified. ## Out of scope -- Multi-project access and project switching — an enterprise capability with its - own project-token endpoint (requirement 5). Enterprise layers it on by issuing a - token for another project; the OSS grant model needs no change to allow that. +- 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/tools/oauth-flow.sh b/tools/oauth-flow.sh index 8886597610..507d958f79 100755 --- a/tools/oauth-flow.sh +++ b/tools/oauth-flow.sh @@ -86,7 +86,7 @@ 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 (the UI does this; there is no consent page yet, so we drive it directly)" +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 || From 3a5314f03f5b95d70a02b9c71970a62f60918c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Wed, 29 Jul 2026 15:03:14 +0100 Subject: [PATCH 17/25] Drop three write-only columns and the responses that carried them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each was written on every row and never consulted for a decision: - oauth_client.scope — a client may send one at registration, but what a token gets is settled by the resource it names, checked at /authorize. Storing the request was a second answer nothing read, and echoing it in the RFC 7591 response implied an enforcement that does not exist. The 128-character validation went with it: refusing a field we ignore is worse than ignoring it quietly. - oauth_grant.scope — restated resourceId, since each resource grants exactly one. It was also carried through the cached grant snapshot, unread. - oauth_refresh_token.userId — copied forward on every rotation while the grant remained authoritative, so the column could only ever disagree. Kept oauth_grant.revokedAt, which is also write-only: it is an audit answer to when, and status alone cannot give that. resourceId stayed too, because it records something not derivable from anything else — whether the connection came through the MCP server or the API. It is now shown on the connected-apps row, which also gives otherwise identical rows something to tell them apart. projectId left the grants response instead: a connection can move between projects, so reporting where it started would mislead. Verified by dropping the schema and letting the migration rebuild it, then running the full flow and the project-switch checks against the result. Co-Authored-By: Claude Opus 5 (1M context) --- docs/oauth-design.md | 30 ++++++++++++------- .../oauth/components/connected-apps-list.tsx | 21 +++++++++++-- .../hooks/tests/use-connected-apps.test.tsx | 2 -- .../hooks/tests/use-oauth-consent.test.tsx | 2 -- .../src/app/features/oauth/lib/oauth-api.ts | 4 --- .../1785312000000-CreateOAuthTables.ts | 3 -- .../api/src/app/oauth/clients.service.ts | 28 ----------------- .../api/src/app/oauth/grants.service.ts | 4 --- .../server/api/src/app/oauth/oauth-model.ts | 20 ++++++++----- .../api/src/app/oauth/oauth.controller.ts | 2 -- .../server/api/src/app/oauth/oauth.entity.ts | 3 -- .../api/src/app/oauth/tokens.service.ts | 5 ---- .../unit/oauth/authorize-validation.test.ts | 1 - .../test/unit/oauth/clients.service.test.ts | 30 ++++++++++--------- .../test/unit/oauth/grants.service.test.ts | 6 ++-- .../test/unit/oauth/tokens.service.test.ts | 1 - tools/oauth-flow.sh | 2 +- 17 files changed, 72 insertions(+), 92 deletions(-) diff --git a/docs/oauth-design.md b/docs/oauth-design.md index 2119f75134..1650054a89 100644 --- a/docs/oauth-design.md +++ b/docs/oauth-design.md @@ -235,7 +235,7 @@ none` (public, PKCE-only). `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`, `projectId`, `resourceId`, `scope`, +`id`, `clientId`, `userId`, `projectId`, `resourceId`, `status (active|revoked)`, `createdAt`, `lastUsedAt`, `revokedAt`. The index on `(clientId, userId)` is deliberately **not unique**. Authorizing the @@ -314,8 +314,10 @@ minting if no project is asked for. It does not decide what a live token can do. `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), `scope`, timestamps. - Usage is recorded per connection on the grant, not per client. + `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 @@ -323,10 +325,14 @@ minting if no project is asked for. It does not decide what a live token can do. - `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`, `userId`, `resource`, `scope`, `expiresAt` - (**indexed**), `revokedAt`. + `familyId` (**indexed**), `clientId`, `resource`, `scope`, `expiresAt` + (**indexed**), `revokedAt`. No `userId`: the grant records the acting user and is + authoritative, so a copy here could only ever disagree. - `oauth_grant` — as above; FKs with `ON DELETE CASCADE`; no defaulted-to-`''` - columns (L3); `(clientId, userId)` indexed but **not** unique. + columns (L3); `(clientId, userId)` indexed but **not** unique. No `scope`: it would + restate `resourceId`, since each resource grants exactly one. `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). @@ -541,10 +547,14 @@ document originally specified. 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. **`oauth_client` has no usage column and signing keys have no `alg` column.** - Both were written and never read: usage is meaningful per connection (on the - grant), and the server signs with one algorithm, which the JWKS reports from a - constant. Removed rather than left as write-only fields. +7. **Five 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. 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. 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 index 660c8de3b0..ef5daae423 100644 --- 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 @@ -2,7 +2,22 @@ import { formatUtils } from '@/app/lib/utils'; import { Button } from '@openops/components/ui'; import { t } from 'i18next'; import { Plug } from 'lucide-react'; -import { ConnectedApp } from '../lib/oauth-api'; +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[]; @@ -37,7 +52,9 @@ const ConnectedAppRow = ({
{app.clientName} - {t('Connected')} {formatUtils.formatDate(new Date(app.created))} + {describeResource(app.resourceId)} + {' · '} + {t('connected')} {formatUtils.formatDate(new Date(app.created))} {' · '} {app.lastUsedAt ? `${t('last used')} ${formatUtils.formatDate( 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 index cc48dc303a..5bd2d6823d 100644 --- 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 @@ -14,9 +14,7 @@ const mockedRevoke = oauthApi.revokeConnectedApp as jest.Mock; const app = (id: string, clientName = 'Claude Code'): ConnectedApp => ({ id, clientName, - scope: 'mcp', resourceId: 'mcp', - projectId: 'proj-1', created: '2026-07-01T10:00:00.000Z', lastUsedAt: null, }); 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 index a9a949fbb4..d86b224ed7 100644 --- 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 @@ -14,8 +14,6 @@ const mockedDecide = oauthApi.decide as jest.Mock; const REQUEST: OAuthConsentRequest = { requestId: 'req-1', clientName: 'Claude Code', - scope: 'mcp', - resourceId: 'mcp', }; const assign = jest.fn(); 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 index 18f45d593c..6d82671c00 100644 --- a/packages/react-ui/src/app/features/oauth/lib/oauth-api.ts +++ b/packages/react-ui/src/app/features/oauth/lib/oauth-api.ts @@ -11,8 +11,6 @@ export type OAuthResourceId = 'api' | 'mcp'; export type OAuthConsentRequest = { requestId: string; clientName: string; - scope: string; - resourceId: OAuthResourceId | null; }; export type OAuthConsentDecision = { @@ -24,9 +22,7 @@ export type OAuthConsentDecision = { export type ConnectedApp = { id: string; clientName: string; - scope: string; resourceId: OAuthResourceId | null; - projectId: string; created: string; lastUsedAt: string | null; }; diff --git a/packages/server/api/src/app/database/migrations/1785312000000-CreateOAuthTables.ts b/packages/server/api/src/app/database/migrations/1785312000000-CreateOAuthTables.ts index 60a8029e21..d2e1d71129 100644 --- a/packages/server/api/src/app/database/migrations/1785312000000-CreateOAuthTables.ts +++ b/packages/server/api/src/app/database/migrations/1785312000000-CreateOAuthTables.ts @@ -33,7 +33,6 @@ export class CreateOAuthTables1785312000000 implements MigrationInterface { "grantTypes" jsonb NOT NULL, "tokenEndpointAuthMethod" varchar(32) NOT NULL, "clientSecretHash" varchar(64), - "scope" varchar(128) NOT NULL, CONSTRAINT "PK_oauth_client" PRIMARY KEY ("id") ); `); @@ -47,7 +46,6 @@ export class CreateOAuthTables1785312000000 implements MigrationInterface { "userId" varchar(21) NOT NULL, "projectId" varchar(21) NOT NULL, "resourceId" varchar(32) NOT NULL, - "scope" varchar(128) NOT NULL, "status" varchar(16) NOT NULL, "lastUsedAt" timestamp with time zone, "revokedAt" timestamp with time zone, @@ -133,7 +131,6 @@ export class CreateOAuthTables1785312000000 implements MigrationInterface { "grantId" varchar(21) NOT NULL, "familyId" varchar(21) NOT NULL, "clientId" varchar(21) NOT NULL, - "userId" varchar(21) NOT NULL, "resource" varchar(512) NOT NULL, "scope" varchar(128) NOT NULL, "expiresAt" timestamp with time zone NOT NULL, diff --git a/packages/server/api/src/app/oauth/clients.service.ts b/packages/server/api/src/app/oauth/clients.service.ts index 14c863fda4..5d92392073 100644 --- a/packages/server/api/src/app/oauth/clients.service.ts +++ b/packages/server/api/src/app/oauth/clients.service.ts @@ -24,7 +24,6 @@ export const TOKEN_EXCHANGE_GRANT = 'urn:ietf:params:oauth:grant-type:token-exchange'; const RS_CLIENT_NAME = 'OpenOps MCP Resource Server'; -const RS_CLIENT_SCOPE = 'mcp'; const RS_CLIENT_SECRET_MIN_LENGTH = 32; const UNIQUE_VIOLATION = '23505'; @@ -40,7 +39,6 @@ const UNMATCHABLE_HASH = '-'.repeat(64); const REGISTRABLE_GRANT_TYPES = ['authorization_code', 'refresh_token']; const MAX_CLIENT_NAME_LENGTH = 128; -const MAX_SCOPE_LENGTH = 128; const MAX_REDIRECT_URIS = 10; export type RegisteredClientResponse = { @@ -49,7 +47,6 @@ export type RegisteredClientResponse = { redirect_uris: string[]; grant_types: string[]; token_endpoint_auth_method: OAuthTokenEndpointAuthMethod; - scope: string; client_id_issued_at: number; }; @@ -57,7 +54,6 @@ type ClientRegistrationMetadata = { clientName: string; redirectUris: string[]; grantTypes: string[]; - scope: string; }; function parseClientName(value: unknown): string { @@ -119,26 +115,6 @@ function parseGrantTypes(value: unknown): string[] { return value as string[]; } -function parseScope(value: unknown): string { - // Left empty on purpose: the authorize endpoint applies the requested - // resource's default scope, which registration cannot know yet. - if (value === undefined) { - return ''; - } - - if (typeof value !== 'string') { - throw invalidClientMetadata('scope must be a string'); - } - - if (value.length > MAX_SCOPE_LENGTH) { - throw invalidClientMetadata( - `scope must be at most ${MAX_SCOPE_LENGTH} characters`, - ); - } - - return value; -} - function assertPublicAuthMethod(value: unknown): void { if (value !== undefined && value !== 'none') { throw invalidClientMetadata( @@ -159,7 +135,6 @@ function parseRegistrationMetadata(body: unknown): ClientRegistrationMetadata { clientName: parseClientName(metadata['client_name']), redirectUris: parseRedirectUris(metadata['redirect_uris']), grantTypes: parseGrantTypes(metadata['grant_types']), - scope: parseScope(metadata['scope']), }; } @@ -215,7 +190,6 @@ export const clientsService = { grantTypes: metadata.grantTypes, tokenEndpointAuthMethod: 'none', clientSecretHash: null, - scope: metadata.scope, }; await repo().save(client); @@ -230,7 +204,6 @@ export const clientsService = { redirect_uris: client.redirectUris, grant_types: client.grantTypes, token_endpoint_auth_method: client.tokenEndpointAuthMethod, - scope: client.scope, client_id_issued_at: Math.floor( new Date(client.created).getTime() / 1000, ), @@ -355,7 +328,6 @@ export const clientsService = { grantTypes: [TOKEN_EXCHANGE_GRANT], tokenEndpointAuthMethod: 'client_secret_basic', clientSecretHash: secretHash, - scope: RS_CLIENT_SCOPE, }); logger.info('OAuth resource server client created'); } catch (error) { diff --git a/packages/server/api/src/app/oauth/grants.service.ts b/packages/server/api/src/app/oauth/grants.service.ts index 0cb45e91b9..d93cf57423 100644 --- a/packages/server/api/src/app/oauth/grants.service.ts +++ b/packages/server/api/src/app/oauth/grants.service.ts @@ -25,7 +25,6 @@ export type GrantSnapshot = { userId: string; clientId: string; projectId: string; - scope: string; status: OAuthGrant['status']; }; @@ -50,7 +49,6 @@ function toSnapshot(grant: OAuthGrant): GrantSnapshot { userId: grant.userId, clientId: grant.clientId, projectId: grant.projectId, - scope: grant.scope, status: grant.status, }; } @@ -62,7 +60,6 @@ function invalidateSnapshot(grantId: string): void { export type CreateGrantParams = { clientId: string; userId: string; - scope: string; resourceId: string; projectId: string; }; @@ -87,7 +84,6 @@ export const grantsService = { userId: params.userId, projectId: params.projectId, resourceId: params.resourceId, - scope: params.scope, status: 'active', lastUsedAt: null, revokedAt: null, diff --git a/packages/server/api/src/app/oauth/oauth-model.ts b/packages/server/api/src/app/oauth/oauth-model.ts index 36511fa645..0ab80c50de 100644 --- a/packages/server/api/src/app/oauth/oauth-model.ts +++ b/packages/server/api/src/app/oauth/oauth-model.ts @@ -11,13 +11,17 @@ export type OAuthSigningKey = BaseModel & { 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; - scope: string; }; /** @@ -49,13 +53,13 @@ export type OAuthAuthorizationCode = BaseModel & { 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; - userId: string; resource: string; scope: string; expiresAt: string; @@ -68,17 +72,19 @@ 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. * - * `projectId` is fixed when the authorization is granted, matching the project - * the user was signed in to. Multi-project access is an enterprise capability - * layered on top; the OSS server issues tokens for exactly one project and never - * mutates that choice. + * `projectId` records where the connection started, and is the default used when minting + * if no project is asked for. It does not limit the connection: a token may name any + * project the user belongs to, so this is not rewritten when one does. + * + * 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; projectId: string; resourceId: string; - scope: string; status: OAuthGrantStatus; lastUsedAt: string | null; revokedAt: string | null; diff --git a/packages/server/api/src/app/oauth/oauth.controller.ts b/packages/server/api/src/app/oauth/oauth.controller.ts index 9e113e1c88..fb742b52a5 100644 --- a/packages/server/api/src/app/oauth/oauth.controller.ts +++ b/packages/server/api/src/app/oauth/oauth.controller.ts @@ -375,9 +375,7 @@ export const oauthController: FastifyPluginAsyncTypebox = async (app) => { data: grants.map((grant, index) => ({ id: grant.id, clientName: clients[index]?.clientName ?? 'Unknown application', - scope: grant.scope, resourceId: grant.resourceId, - projectId: grant.projectId, created: grant.created, lastUsedAt: grant.lastUsedAt, })), diff --git a/packages/server/api/src/app/oauth/oauth.entity.ts b/packages/server/api/src/app/oauth/oauth.entity.ts index 397deb5653..345e7bcf08 100644 --- a/packages/server/api/src/app/oauth/oauth.entity.ts +++ b/packages/server/api/src/app/oauth/oauth.entity.ts @@ -51,7 +51,6 @@ export const OAuthClientEntity = new EntitySchema({ length: SHA256_HEX_LENGTH, nullable: true, }, - scope: { type: String, length: 128 }, }, indices: [], }); @@ -114,7 +113,6 @@ export const OAuthRefreshTokenEntity = new EntitySchema({ grantId: { ...OpenOpsIdSchema }, familyId: { ...OpenOpsIdSchema }, clientId: { ...OpenOpsIdSchema }, - userId: { ...OpenOpsIdSchema }, resource: { type: String, length: URI_LENGTH }, scope: { type: String, length: 128 }, expiresAt: { type: TIMESTAMP_COLUMN_TYPE }, @@ -140,7 +138,6 @@ export const OAuthGrantEntity = new EntitySchema({ userId: { ...OpenOpsIdSchema }, projectId: { ...OpenOpsIdSchema }, resourceId: { type: String, length: 32 }, - scope: { type: String, length: 128 }, status: { type: String, length: 16 }, lastUsedAt: { type: TIMESTAMP_COLUMN_TYPE, nullable: true }, revokedAt: { type: TIMESTAMP_COLUMN_TYPE, nullable: true }, diff --git a/packages/server/api/src/app/oauth/tokens.service.ts b/packages/server/api/src/app/oauth/tokens.service.ts index 113ec6f188..fafa9c81d3 100644 --- a/packages/server/api/src/app/oauth/tokens.service.ts +++ b/packages/server/api/src/app/oauth/tokens.service.ts @@ -118,7 +118,6 @@ async function issueRefreshToken(params: { grantId: string; familyId: string; clientId: string; - userId: string; resource: string; scope: string; }): Promise { @@ -136,7 +135,6 @@ async function issueRefreshToken(params: { grantId: params.grantId, familyId: params.familyId, clientId: params.clientId, - userId: params.userId, resource: params.resource, scope: params.scope, expiresAt: expiresAt.toISOString(), @@ -241,7 +239,6 @@ export const tokensService = { const grant = await grantsService.create({ clientId: codeRecord.clientId, userId: codeRecord.userId, - scope: codeRecord.scope, resourceId: resource.id, projectId: await resolveDefaultProjectId(user), }); @@ -258,7 +255,6 @@ export const tokensService = { grantId: grant.id, familyId: openOpsId(), clientId: grant.clientId, - userId: grant.userId, resource: resource.canonicalUri, scope: codeRecord.scope, }); @@ -364,7 +360,6 @@ export const tokensService = { // Same family: rotation forms a chain, and reuse anywhere in it is fatal. familyId: existingToken.familyId, clientId: existingToken.clientId, - userId: existingToken.userId, resource: existingToken.resource, scope: existingToken.scope, }); diff --git a/packages/server/api/test/unit/oauth/authorize-validation.test.ts b/packages/server/api/test/unit/oauth/authorize-validation.test.ts index d1fc52fa8c..0187ad2122 100644 --- a/packages/server/api/test/unit/oauth/authorize-validation.test.ts +++ b/packages/server/api/test/unit/oauth/authorize-validation.test.ts @@ -23,7 +23,6 @@ const CLIENT: OAuthClient = { grantTypes: ['authorization_code', 'refresh_token'], tokenEndpointAuthMethod: 'none', clientSecretHash: null, - scope: '', }; function query(overrides: Record = {}): AuthorizeQuery { diff --git a/packages/server/api/test/unit/oauth/clients.service.test.ts b/packages/server/api/test/unit/oauth/clients.service.test.ts index ff55350a84..5bcd1d4b78 100644 --- a/packages/server/api/test/unit/oauth/clients.service.test.ts +++ b/packages/server/api/test/unit/oauth/clients.service.test.ts @@ -85,7 +85,6 @@ describe('clientsService', () => { 'refresh_token', ]); expect(response.token_endpoint_auth_method).toBe('none'); - expect(response.scope).toBe(''); expect(response.client_id_issued_at).toBeLessThanOrEqual( Math.floor(Date.now() / 1000), ); @@ -101,22 +100,36 @@ describe('clientsService', () => { 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'], - scope: 'mcp', }); expect(response.grant_types).toEqual(['authorization_code']); - expect(response.scope).toBe('mcp'); 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({ @@ -206,15 +219,6 @@ describe('clientsService', () => { ).rejects.toThrow('invalid_client_metadata'); expect(clientRows).toHaveLength(0); }); - - it('rejects a scope over 128 characters', async () => { - await expect( - clientsService.registerClient({ - ...validMetadata(), - scope: 's'.repeat(129), - }), - ).rejects.toThrow('invalid_client_metadata'); - }); }); describe('getClient / getClientOrThrow', () => { @@ -259,7 +263,6 @@ describe('clientsService', () => { grantTypes, tokenEndpointAuthMethod: 'none', clientSecretHash: null, - scope: '', } as OAuthClient); it('allows a grant type the client registered', () => { @@ -337,7 +340,6 @@ describe('clientsService', () => { expect(row.grantTypes).toEqual([TOKEN_EXCHANGE_GRANT]); expect(row.tokenEndpointAuthMethod).toBe('client_secret_basic'); expect(row.clientSecretHash).toBe(sha256Hex(RS_SECRET)); - expect(row.scope).toBe('mcp'); expect(JSON.stringify(row)).not.toContain(RS_SECRET); }); diff --git a/packages/server/api/test/unit/oauth/grants.service.test.ts b/packages/server/api/test/unit/oauth/grants.service.test.ts index 43e6297cc3..15baa37500 100644 --- a/packages/server/api/test/unit/oauth/grants.service.test.ts +++ b/packages/server/api/test/unit/oauth/grants.service.test.ts @@ -55,7 +55,6 @@ import { grantsService } from '../../../src/app/oauth/grants.service'; const BASE_PARAMS = { clientId: 'client-1', userId: 'user-1', - scope: 'mcp', resourceId: 'mcp', projectId: 'project-1', }; @@ -67,7 +66,6 @@ function seedRefreshToken(overrides: Row = {}): Row { grantId: 'grant-1', familyId: 'family-1', clientId: 'client-1', - userId: 'user-1', resource: 'https://ops.example.com/mcp', scope: 'mcp', expiresAt: new Date(Date.now() + 86_400_000).toISOString(), @@ -95,10 +93,12 @@ describe('grantsService', () => { userId: 'user-1', projectId: 'project-1', resourceId: 'mcp', - scope: '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 () => { diff --git a/packages/server/api/test/unit/oauth/tokens.service.test.ts b/packages/server/api/test/unit/oauth/tokens.service.test.ts index 4ce247df17..29ad3ba5b3 100644 --- a/packages/server/api/test/unit/oauth/tokens.service.test.ts +++ b/packages/server/api/test/unit/oauth/tokens.service.test.ts @@ -275,7 +275,6 @@ describe('tokensService', () => { expect(grantsService.create).toHaveBeenCalledWith({ clientId: 'client-1', userId: 'user-1', - scope: 'mcp', resourceId: 'mcp', projectId: 'project-1', }); diff --git a/tools/oauth-flow.sh b/tools/oauth-flow.sh index 507d958f79..a36948c775 100755 --- a/tools/oauth-flow.sh +++ b/tools/oauth-flow.sh @@ -160,7 +160,7 @@ 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']} project={g['projectId']} last used={g['lastUsedAt']}\")" + print(f\" {g['clientName']} grant={g['id']} via={g['resourceId']} last used={g['lastUsedAt']}\")" GRANT_ID="$(python3 -c " import base64, json From 458da692e6a7bf08114dd49056304c1f76faf79b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Wed, 29 Jul 2026 15:30:03 +0100 Subject: [PATCH 18/25] Move the project off the grant, onto the refresh token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answering whether projectId belongs on oauth_grant turned up a bug. It was read in one place — the default when a refresh names no project — and that default was wrong. A connection that switched to another project and then renewed normally was silently put back where it started, roughly fifteen minutes later, with nothing in the request to attribute it to. The project is a property of the credential chain, not of the authorization, so it now lives on oauth_refresh_token. Rotation carries it forward unless the client asks to move, which makes a plain renewal hand back an equivalent credential — the thing a renewal is supposed to be. That leaves nothing reading the grant's copy, so the column is gone along with it, as is the projectId that had crept onto the grant snapshot. Two mocks were hiding this. tokens.service.test stubbed getForUser with a fixed membership, so every caller looked correct regardless of which project it passed; it now echoes the project it is asked about, as the real service does. The first version of the regression test then made the same mistake locally and passed against the bug. Verified by dropping the schema, letting the migration rebuild it, and driving a switch followed by a plain refresh against a second project. Co-Authored-By: Claude Opus 5 (1M context) --- docs/oauth-design.md | 33 ++++++++++---- .../1785312000000-CreateOAuthTables.ts | 2 +- .../api/src/app/oauth/grants.service.ts | 4 -- .../server/api/src/app/oauth/oauth-model.ts | 14 ++++-- .../server/api/src/app/oauth/oauth.entity.ts | 2 +- .../api/src/app/oauth/tokens.service.ts | 18 +++++--- .../test/unit/oauth/grants.service.test.ts | 12 ++--- .../test/unit/oauth/tokens.service.test.ts | 44 ++++++++++++++++++- 8 files changed, 98 insertions(+), 31 deletions(-) diff --git a/docs/oauth-design.md b/docs/oauth-design.md index 1650054a89..016c048256 100644 --- a/docs/oauth-design.md +++ b/docs/oauth-design.md @@ -235,7 +235,7 @@ none` (public, PKCE-only). `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`, `projectId`, `resourceId`, +`id`, `clientId`, `userId`, `resourceId`, `status (active|revoked)`, `createdAt`, `lastUsedAt`, `revokedAt`. The index on `(clientId, userId)` is deliberately **not unique**. Authorizing the @@ -304,8 +304,14 @@ 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. -`oauth_grant.projectId` records where the connection started — the default used when -minting if no project is asked for. It does not decide what a live token can do. +**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) @@ -325,14 +331,16 @@ minting if no project is asked for. It does not decide what a live token can do. - `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`, `expiresAt` + `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. + 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. `revokedAt` is - write-only on purpose — `status` is what code branches on, and this answers "when" - for anyone auditing later. + 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). @@ -547,7 +555,7 @@ document originally specified. 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. **Five columns the design named were removed as write-only.** `oauth_client.lastUsedAt` +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 @@ -555,6 +563,13 @@ document originally specified. 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. diff --git a/packages/server/api/src/app/database/migrations/1785312000000-CreateOAuthTables.ts b/packages/server/api/src/app/database/migrations/1785312000000-CreateOAuthTables.ts index d2e1d71129..fa2a315186 100644 --- a/packages/server/api/src/app/database/migrations/1785312000000-CreateOAuthTables.ts +++ b/packages/server/api/src/app/database/migrations/1785312000000-CreateOAuthTables.ts @@ -44,7 +44,6 @@ export class CreateOAuthTables1785312000000 implements MigrationInterface { "updated" timestamp with time zone DEFAULT now() NOT NULL, "clientId" varchar(21) NOT NULL, "userId" varchar(21) NOT NULL, - "projectId" varchar(21) NOT NULL, "resourceId" varchar(32) NOT NULL, "status" varchar(16) NOT NULL, "lastUsedAt" timestamp with time zone, @@ -133,6 +132,7 @@ export class CreateOAuthTables1785312000000 implements MigrationInterface { "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"), diff --git a/packages/server/api/src/app/oauth/grants.service.ts b/packages/server/api/src/app/oauth/grants.service.ts index d93cf57423..0aa187c98d 100644 --- a/packages/server/api/src/app/oauth/grants.service.ts +++ b/packages/server/api/src/app/oauth/grants.service.ts @@ -24,7 +24,6 @@ export type GrantSnapshot = { id: string; userId: string; clientId: string; - projectId: string; status: OAuthGrant['status']; }; @@ -48,7 +47,6 @@ function toSnapshot(grant: OAuthGrant): GrantSnapshot { id: grant.id, userId: grant.userId, clientId: grant.clientId, - projectId: grant.projectId, status: grant.status, }; } @@ -61,7 +59,6 @@ export type CreateGrantParams = { clientId: string; userId: string; resourceId: string; - projectId: string; }; export const grantsService = { @@ -82,7 +79,6 @@ export const grantsService = { updated: now, clientId: params.clientId, userId: params.userId, - projectId: params.projectId, resourceId: params.resourceId, status: 'active', lastUsedAt: null, diff --git a/packages/server/api/src/app/oauth/oauth-model.ts b/packages/server/api/src/app/oauth/oauth-model.ts index 0ab80c50de..71876b8398 100644 --- a/packages/server/api/src/app/oauth/oauth-model.ts +++ b/packages/server/api/src/app/oauth/oauth-model.ts @@ -62,6 +62,12 @@ export type OAuthRefreshToken = BaseModel & { 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; }; @@ -72,9 +78,10 @@ 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. * - * `projectId` records where the connection started, and is the default used when minting - * if no project is asked for. It does not limit the connection: a token may name any - * project the user belongs to, so this is not rewritten when one does. + * 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 @@ -83,7 +90,6 @@ export type OAuthGrantStatus = 'active' | 'revoked'; export type OAuthGrant = BaseModel & { clientId: string; userId: string; - projectId: string; resourceId: string; status: OAuthGrantStatus; lastUsedAt: string | null; diff --git a/packages/server/api/src/app/oauth/oauth.entity.ts b/packages/server/api/src/app/oauth/oauth.entity.ts index 345e7bcf08..84cabec3bc 100644 --- a/packages/server/api/src/app/oauth/oauth.entity.ts +++ b/packages/server/api/src/app/oauth/oauth.entity.ts @@ -115,6 +115,7 @@ export const OAuthRefreshTokenEntity = new EntitySchema({ 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 }, }, @@ -136,7 +137,6 @@ export const OAuthGrantEntity = new EntitySchema({ ...BaseColumnSchemaPart, clientId: { ...OpenOpsIdSchema }, userId: { ...OpenOpsIdSchema }, - projectId: { ...OpenOpsIdSchema }, resourceId: { type: String, length: 32 }, status: { type: String, length: 16 }, lastUsedAt: { type: TIMESTAMP_COLUMN_TYPE, nullable: true }, diff --git a/packages/server/api/src/app/oauth/tokens.service.ts b/packages/server/api/src/app/oauth/tokens.service.ts index fafa9c81d3..f39020067e 100644 --- a/packages/server/api/src/app/oauth/tokens.service.ts +++ b/packages/server/api/src/app/oauth/tokens.service.ts @@ -120,6 +120,7 @@ async function issueRefreshToken(params: { clientId: string; resource: string; scope: string; + projectId: string; }): Promise { const token = generateOpaqueToken(); const now = new Date(); @@ -137,6 +138,7 @@ async function issueRefreshToken(params: { clientId: params.clientId, resource: params.resource, scope: params.scope, + projectId: params.projectId, expiresAt: expiresAt.toISOString(), revokedAt: null, }); @@ -236,18 +238,21 @@ export const tokensService = { } 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, - projectId: await resolveDefaultProjectId(user), }); const accessToken = await mintAccessToken({ grant, audience: resource.audience, scope: codeRecord.scope, - projectId: grant.projectId, + projectId, ttlSeconds: oauthConfig.getAccessTokenTtlSeconds(), }); @@ -257,6 +262,7 @@ export const tokensService = { clientId: grant.clientId, resource: resource.canonicalUri, scope: codeRecord.scope, + projectId, }); return { @@ -301,11 +307,12 @@ export const tokensService = { ); 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. Nothing is stored, so the switch - // lasts exactly as long as the token it produced. + // 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 ?? grant.projectId, + params.requestedProjectId ?? existingToken.projectId, params.requestedProjectId !== undefined, ); @@ -362,6 +369,7 @@ export const tokensService = { clientId: existingToken.clientId, resource: existingToken.resource, scope: existingToken.scope, + projectId, }); return { diff --git a/packages/server/api/test/unit/oauth/grants.service.test.ts b/packages/server/api/test/unit/oauth/grants.service.test.ts index 15baa37500..6dec83ab62 100644 --- a/packages/server/api/test/unit/oauth/grants.service.test.ts +++ b/packages/server/api/test/unit/oauth/grants.service.test.ts @@ -56,7 +56,6 @@ const BASE_PARAMS = { clientId: 'client-1', userId: 'user-1', resourceId: 'mcp', - projectId: 'project-1', }; function seedRefreshToken(overrides: Row = {}): Row { @@ -91,7 +90,6 @@ describe('grantsService', () => { expect(grant).toMatchObject({ clientId: 'client-1', userId: 'user-1', - projectId: 'project-1', resourceId: 'mcp', status: 'active', revokedAt: null, @@ -128,10 +126,14 @@ describe('grantsService', () => { expect(secondToken.revokedAt).toBeNull(); }); - it('fixes the project on the grant and never mutates it', async () => { + it('records no project on the grant', async () => { const grant = await grantsService.create(BASE_PARAMS); - expect(grant.projectId).toBe('project-1'); + // 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); @@ -263,7 +265,7 @@ describe('grantsService', () => { { id: grant.id, userId: 'user-1', - projectId: 'project-1', + status: 'active', }, ); }); diff --git a/packages/server/api/test/unit/oauth/tokens.service.test.ts b/packages/server/api/test/unit/oauth/tokens.service.test.ts index 29ad3ba5b3..710ab30054 100644 --- a/packages/server/api/test/unit/oauth/tokens.service.test.ts +++ b/packages/server/api/test/unit/oauth/tokens.service.test.ts @@ -153,7 +153,14 @@ describe('tokensService', () => { organizationId: 'org-1', }); membershipService.getDefaultForUser.mockResolvedValue(MEMBERSHIP); - membershipService.getForUser.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(() => { @@ -216,6 +223,19 @@ describe('tokensService', () => { }); }); + 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, @@ -276,7 +296,6 @@ describe('tokensService', () => { clientId: 'client-1', userId: 'user-1', resourceId: 'mcp', - projectId: 'project-1', }); }); @@ -534,6 +553,27 @@ describe('tokensService', () => { ); }); + 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(); From 3a904a810eb51f5f846808b170836cb87329a9f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Wed, 29 Jul 2026 16:03:02 +0100 Subject: [PATCH 19/25] Style connected apps after the integrations card, with a red Disconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disconnect is destructive, so it now uses the destructive variant like the other destructive actions in the app. The confirmation it opens was still primary blue, which read oddly for the button that actually does the work, so ConfirmationDialog gained an optional confirmButtonVariant. It defaults to the previous behaviour, leaving the risky-flow dialog untouched. The row now follows the enterprise integrations card: a p-6 bordered box, a 48px icon square standing in for the product logo a self-registered client does not have, gap-6, a semibold title and a full-size button. One class did not survive the copy. text-primary-900 exists in the enterprise Tailwind scale but not this one, which stops at 800, so it resolved to nothing and the secondary line inherited the title colour — measured as identical near-black. Using text-muted-foreground, the convention here, restores the distinction. Dark theme could not be checked visually because DARK_THEME_ENABLED is false locally, but every colour used is a theme token. Co-Authored-By: Claude Opus 5 (1M context) --- .../oauth/components/connected-apps-list.tsx | 47 +++++++++++-------- .../connected-apps/connected-apps-page.tsx | 1 + .../confirmation-dialog.tsx | 11 ++++- 3 files changed, 38 insertions(+), 21 deletions(-) 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 index ef5daae423..1d47fbb19e 100644 --- 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 @@ -26,9 +26,9 @@ type ConnectedAppsListProps = { }; const EmptyState = () => ( -
+
- + {t('No applications are connected')} @@ -48,25 +48,34 @@ const ConnectedAppRow = ({ onRevoke: (app: ConnectedApp) => void; isRevoking: boolean; }) => ( -
-
- {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')} - +
+
+ {/* 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')} + +
)} - From 79f4a76cc69358d0713feea01e714ece4f1f88ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Wed, 29 Jul 2026 16:07:53 +0100 Subject: [PATCH 20/25] Drop the divider under the connected apps heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bordered rows already separate themselves from the description, so the rule was doing no work — and the integrations card this page follows has none either. Removing it left the Separator import unused, so that goes too. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/routes/settings/connected-apps/connected-apps-page.tsx | 2 -- 1 file changed, 2 deletions(-) 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 index 309554e67d..fed122d0dd 100644 --- 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 @@ -9,7 +9,6 @@ import { AlertTitle, ConfirmationDialog, LoadingSpinner, - Separator, } from '@openops/components/ui'; import { t } from 'i18next'; import { useCallback, useState } from 'react'; @@ -60,7 +59,6 @@ const ConnectedAppsPage = () => { )}

- {/* 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. */} From 8337fcc5f285543ab1e1a7472a839b785e107439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Wed, 29 Jul 2026 16:13:11 +0100 Subject: [PATCH 21/25] Match the settings page title to the other settings routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heading was an h3 at text-lg with a small muted description, which read as a section label rather than the page title it is. It now follows routes/settings/ai: an h1 at 24px bold with a text-base description, measured identical to that page. The sibling pages tag these with text-primary-900, which is not defined in this Tailwind scale or as a CSS variable — three files use it and it resolves to nothing. Left off here rather than copied, since it changes no pixel today. Co-Authored-By: Claude Opus 5 (1M context) --- .../connected-apps/connected-apps-page.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 index fed122d0dd..e9e23551b3 100644 --- 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 @@ -49,16 +49,16 @@ const ConnectedAppsPage = () => { const cancelRevoke = useCallback(() => setAppToRevoke(null), []); return ( -
-
-
-

{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.', - )} -

-
+ // 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. */} From 06e5d5c2fdc5f1a9c2910d2c86cd76b98411add5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Wed, 29 Jul 2026 17:43:14 +0100 Subject: [PATCH 22/25] Address PR review: cleanup handler, retention anchor, module-scope t() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review comments on #2415, all correct. Copilot found a real bug. registerOAuthCleanupJob both registered the handler and upserted the repeatable job, and ran only when OAuth was enabled. The schedule lives in Redis and outlives the boot that created it, so an instance that enabled OAuth once and later turned it off kept firing a job with no handler — the BullMQ processor throws from getJobHandler and the job fails hourly. Handler registration is now unconditional and returns early while disabled; scheduling stays on the enabled path. A test asserts the disabled handler touches no repository, and fails if the guard is removed. Retention for revoked refresh tokens was an independent 7-day window against a 30-day refresh TTL, which the reviewer asked about. It was not a considered choice, and their alternative is better: anchoring to expiry means a rotated token is kept exactly as long as it could still be presented, so a replay is recognised as reuse — revoking the family and logging it — instead of coming back as a plain invalid token because it was old. That deletes a constant and a query rather than adding a comment defending the window. The nav-item titles called t() at module scope, which the reviewer flagged as a new instance of OPS-4318. A production chunk can evaluate before i18n.init(), and t() returns undefined until then, freezing a blank label into a top-level constant. Building the items inside the component removes all four hits in that file, so the audit script counts 17 files where it previously counted 18. Co-Authored-By: Claude Opus 5 (1M context) --- docs/oauth-design.md | 14 ++- .../components/project-settings-layout.tsx | 74 ++++++++------ packages/server/api/src/app/app.ts | 5 + .../api/src/app/oauth/oauth-cleanup-job.ts | 41 +++++--- .../server/api/src/app/oauth/oauth.module.ts | 4 +- .../test/unit/oauth/oauth-cleanup-job.test.ts | 22 +++-- .../oauth/oauth-cleanup-registration.test.ts | 98 +++++++++++++++++++ 7 files changed, 203 insertions(+), 55 deletions(-) create mode 100644 packages/server/api/test/unit/oauth/oauth-cleanup-registration.test.ts diff --git a/docs/oauth-design.md b/docs/oauth-design.md index 016c048256..b00ae5db4c 100644 --- a/docs/oauth-design.md +++ b/docs/oauth-design.md @@ -423,8 +423,20 @@ user decides where they later review and revoke. - 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/revoked refresh tokens; stale-client removal via + 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. 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 875f36e9ba..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,38 +1,13 @@ import { FlagId } from '@openops/shared'; import { t } from 'i18next'; 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: , -}; - -const connectedAppsNavItem = { - title: t('Connected apps'), - href: '/settings/connected-apps', - icon: , -}; - interface SettingsLayoutProps { children: React.ReactNode; } @@ -50,12 +25,47 @@ export default function ProjectSettingsLayout({ FlagId.CONNECTED_APPS_ENABLED, ).data; - const sidebarNavItems = [ - ...baseNavItems, - ...(showAppearanceSettings ? [appearanceNavItem] : []), - aiNavItem, - ...(showConnectedApps ? [connectedAppsNavItem] : []), - ]; + /* + * 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/server/api/src/app/app.ts b/packages/server/api/src/app/app.ts index 818440996b..f5686552e6 100644 --- a/packages/server/api/src/app/app.ts +++ b/packages/server/api/src/app/app.ts @@ -53,6 +53,7 @@ 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'; @@ -227,6 +228,10 @@ 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); } diff --git a/packages/server/api/src/app/oauth/oauth-cleanup-job.ts b/packages/server/api/src/app/oauth/oauth-cleanup-job.ts index 333542d5de..b02e93a9dd 100644 --- a/packages/server/api/src/app/oauth/oauth-cleanup-job.ts +++ b/packages/server/api/src/app/oauth/oauth-cleanup-job.ts @@ -3,6 +3,7 @@ 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, @@ -29,10 +30,22 @@ const grantRepo = repoFactory(OAuthGrantEntity); export const OAUTH_CLEANUP_CRON = '0 * * * *'; -export const registerOAuthCleanupJob = async (): Promise => { +/** + * 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) { @@ -41,7 +54,9 @@ export const registerOAuthCleanupJob = async (): Promise => { } }, ); +}; +export const scheduleOAuthCleanupJob = async (): Promise => { await systemJobsSchedule.upsertJob({ job: { name: SystemJobName.OAUTH_CLEANUP, @@ -56,12 +71,6 @@ export const registerOAuthCleanupJob = async (): Promise => { const DAY_MS = 24 * 60 * 60 * 1000; -/** - * Revoked refresh tokens are kept for a while so rotation reuse detection still - * has the history it needs to recognize a replay of an old token. - */ -const REVOKED_RETENTION_DAYS = 7; - /** Registration is open to the network, so unused clients must not accumulate. */ const UNUSED_CLIENT_RETENTION_DAYS = 30; @@ -78,7 +87,6 @@ export const oauthCleanupJobHandler = async (): Promise => { // 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 revokedCutoff = new Date(now - REVOKED_RETENTION_DAYS * DAY_MS); const clientCutoff = new Date(now - UNUSED_CLIENT_RETENTION_DAYS * DAY_MS); const deadGrantCutoff = new Date(now - DEAD_GRANT_RETENTION_DAYS * DAY_MS); @@ -88,13 +96,21 @@ export const oauthCleanupJobHandler = async (): Promise => { const pendingAuthorizations = await pendingAuthorizationService.deleteExpired( nowDate, ); - // An expired refresh token can no longer be rotated, so nothing depends on it. + /* + * 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), }); - const revokedRefreshTokens = await refreshTokenRepo().delete({ - revokedAt: earlierThan(revokedCutoff), - }); // 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 @@ -127,7 +143,6 @@ export const oauthCleanupJobHandler = async (): Promise => { authorizationCodes: authorizationCodes.affected ?? 0, pendingAuthorizations, expiredRefreshTokens: expiredRefreshTokens.affected ?? 0, - revokedRefreshTokens: revokedRefreshTokens.affected ?? 0, unusedClients: unusedClients.affected ?? 0, deadGrants: deadGrants.affected ?? 0, }); diff --git a/packages/server/api/src/app/oauth/oauth.module.ts b/packages/server/api/src/app/oauth/oauth.module.ts index 90af223a93..57370cca93 100644 --- a/packages/server/api/src/app/oauth/oauth.module.ts +++ b/packages/server/api/src/app/oauth/oauth.module.ts @@ -1,7 +1,7 @@ import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'; import { logger } from '@openops/server-shared'; import { clientsService } from './clients.service'; -import { registerOAuthCleanupJob } from './oauth-cleanup-job'; +import { scheduleOAuthCleanupJob } from './oauth-cleanup-job'; import { validateOAuthConfiguration } from './oauth-config-validation'; import { OAuthError } from './oauth-errors'; import { oauthWellKnownController } from './oauth-well-known.controller'; @@ -13,7 +13,7 @@ export const oauthModule: FastifyPluginAsyncTypebox = async (app) => { await signingKeyService.ensureSigningKey(); await clientsService.ensureResourceServerClient(); - await registerOAuthCleanupJob(); + await scheduleOAuthCleanupJob(); await app.register( async (instance) => { 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 index 6cc719a7c1..df190ec914 100644 --- a/packages/server/api/test/unit/oauth/oauth-cleanup-job.test.ts +++ b/packages/server/api/test/unit/oauth/oauth-cleanup-job.test.ts @@ -194,28 +194,37 @@ describe('oauthCleanupJobHandler', () => { expect(refreshRows.map((row) => row.id)).toEqual(['live-token']); }); - it('keeps recently revoked refresh tokens so reuse detection still has history', async () => { + it('keeps revoked refresh tokens until they expire, however long ago they were rotated', async () => { refreshRows.push( { - id: 'revoked-long-ago', - expiresAt: isoDaysAgo(-30), - revokedAt: isoDaysAgo(8), + id: 'revoked-long-ago-still-valid', + expiresAt: isoDaysAgo(-20), + revokedAt: isoDaysAgo(25), }, { id: 'revoked-recently', - expiresAt: isoDaysAgo(-30), + expiresAt: isoDaysAgo(-20), revokedAt: isoDaysAgo(1), }, + { + id: 'revoked-and-expired', + expiresAt: isoDaysAgo(1), + revokedAt: isoDaysAgo(25), + }, { id: 'never-revoked', - expiresAt: isoDaysAgo(-30), + 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', ]); @@ -270,7 +279,6 @@ describe('oauthCleanupJobHandler', () => { authorizationCodes: 1, pendingAuthorizations: 1, expiredRefreshTokens: 1, - revokedRefreshTokens: 1, unusedClients: 2, deadGrants: 1, }); 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(); + }); +}); From 5a1c6f116c24fcd19a34096ceb2e83da78c31851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Thu, 30 Jul 2026 09:01:58 +0100 Subject: [PATCH 23/25] Import accessTokenManager after the mocks in the signup test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This suite failed in CI on every retry with `Cannot access 'authUserMock' before initialization`. It imported accessTokenManager on line 1, ahead of the const that its jest.mock('@openops/common') factory closes over; every other import in the file already sat below the mocks. The OAuth work is what exposed it. access-token-manager now reaches @openops/common transitively — through signing-key.service and service-principal into repo-factory, the database connection and the migrations barrel — so the factory started running during that first import, while authUserMock was still in its temporal dead zone. Breaking one of those imports would not help: signing-key.service pulls in repo-factory on its own, so the graph is reachable either way. The ordering in the test is the fix. Verified by loading the module with a test-name pattern that matches nothing, which isolates module evaluation from the server boot: clean now, and the exact CI ReferenceError when the import is moved back. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/test/integration/ce/authentication/signup.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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'; From b975ebd093145fae97077fb79ae0d82a6dc46ae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Thu, 30 Jul 2026 09:46:39 +0100 Subject: [PATCH 24/25] Fix the SonarCloud findings worth fixing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regex one had teeth. `/\/+$/` appeared five times to strip trailing slashes, and Sonar was right that it backtracks: measured 145 ms for 20k slashes, 8.8 s for 160k, quadratic. One of the five normalizes the client-supplied `resource` parameter on /authorize and /token, which are public and had no length cap on that field — so an unauthenticated request could hold the event loop for seconds. Replaced by one shared helper that scans backwards, verified identical on every edge case and 0.03 ms on the 160k input. That also removes the five-way duplication. Also fixed: escapeHtml now uses replaceAll with string literals rather than four global regexes, one `!x || x.y !== z` became an optional chain, one assertion became toHaveLength for a better failure message, and the flow script uses `[[` and names its positional parameters. Left alone: two optional-chain suggestions in pending-authorization.service and redirect-uri. In both, the `!record ||` / `!url ||` clause narrows the value for code after it — `isExpired(record)` and `url.protocol` respectively — so applying the suggestion would either fail to compile or need a non-null assertion the lint config forbids. The guard is doing real work, not stating something redundant. Co-Authored-By: Claude Opus 5 (1M context) --- .../server/api/src/app/oauth/canonical-url.ts | 22 ++++++++++++ .../api/src/app/oauth/grants.service.ts | 2 +- .../src/app/oauth/oauth-config-validation.ts | 6 ++-- .../server/api/src/app/oauth/oauth-config.ts | 5 +-- .../api/src/app/oauth/oauth-metadata.ts | 6 ++-- .../api/src/app/oauth/oauth.controller.ts | 15 ++++---- .../api/src/app/oauth/resource-registry.ts | 3 +- .../api/test/unit/oauth/canonical-url.test.ts | 36 +++++++++++++++++++ .../test/unit/oauth/clients.service.test.ts | 2 +- tools/oauth-flow.sh | 18 ++++++---- 10 files changed, 88 insertions(+), 27 deletions(-) create mode 100644 packages/server/api/src/app/oauth/canonical-url.ts create mode 100644 packages/server/api/test/unit/oauth/canonical-url.test.ts 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/grants.service.ts b/packages/server/api/src/app/oauth/grants.service.ts index 0aa187c98d..6af499f302 100644 --- a/packages/server/api/src/app/oauth/grants.service.ts +++ b/packages/server/api/src/app/oauth/grants.service.ts @@ -106,7 +106,7 @@ export const grantsService = { async getActiveGrantOrThrow(grantId: string): Promise { const snapshot = await grantsService.getGrantSnapshot(grantId); - if (!snapshot || snapshot.status !== 'active') { + if (snapshot?.status !== 'active') { throw invalidGrant('the authorization for this client has been revoked'); } diff --git a/packages/server/api/src/app/oauth/oauth-config-validation.ts b/packages/server/api/src/app/oauth/oauth-config-validation.ts index ea7f666bfe..38a2969f88 100644 --- a/packages/server/api/src/app/oauth/oauth-config-validation.ts +++ b/packages/server/api/src/app/oauth/oauth-config-validation.ts @@ -1,5 +1,6 @@ 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'; @@ -19,9 +20,8 @@ function invalidProp(prop: string, message: string): ApplicationError { function canonicalize(audience: string): string { try { const url = new URL(audience); - return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${url.pathname.replace( - /\/+$/, - '', + return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${stripTrailingSlashes( + url.pathname, )}`; } catch { return audience; diff --git a/packages/server/api/src/app/oauth/oauth-config.ts b/packages/server/api/src/app/oauth/oauth-config.ts index c292254eb5..c886d52263 100644 --- a/packages/server/api/src/app/oauth/oauth-config.ts +++ b/packages/server/api/src/app/oauth/oauth-config.ts @@ -1,8 +1,5 @@ import { AppSystemProp, system } from '@openops/server-shared'; - -function stripTrailingSlashes(value: string): string { - return value.replace(/\/+$/, ''); -} +import { stripTrailingSlashes } from './canonical-url'; export const oauthConfig = { isEnabled(): boolean { diff --git a/packages/server/api/src/app/oauth/oauth-metadata.ts b/packages/server/api/src/app/oauth/oauth-metadata.ts index 89dda27748..3f6a2e7b99 100644 --- a/packages/server/api/src/app/oauth/oauth-metadata.ts +++ b/packages/server/api/src/app/oauth/oauth-metadata.ts @@ -1,3 +1,4 @@ +import { stripTrailingSlashes } from './canonical-url'; import { oauthConfig } from './oauth-config'; import { getSupportedScopes } from './resource-registry'; @@ -47,9 +48,8 @@ export function buildAuthorizationServerMetadata(): AuthorizationServerMetadata * own path component, so an issuer served under a sub-path is discoverable. */ export function getWellKnownPathVariants(basePath: string): string[] { - const issuerPath = new URL(oauthConfig.getIssuerUrl()).pathname.replace( - /\/+$/, - '', + const issuerPath = stripTrailingSlashes( + new URL(oauthConfig.getIssuerUrl()).pathname, ); return issuerPath ? [basePath, `${basePath}${issuerPath}`] : [basePath]; diff --git a/packages/server/api/src/app/oauth/oauth.controller.ts b/packages/server/api/src/app/oauth/oauth.controller.ts index fb742b52a5..d96abb2956 100644 --- a/packages/server/api/src/app/oauth/oauth.controller.ts +++ b/packages/server/api/src/app/oauth/oauth.controller.ts @@ -17,6 +17,7 @@ import { 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'; @@ -81,10 +82,10 @@ function renderAuthorizeError( function escapeHtml(value: string): string { return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); } function noStore(reply: FastifyReply): FastifyReply { @@ -96,9 +97,9 @@ function noStore(reply: FastifyReply): FastifyReply { * the user decides in the same place they later review and revoke what they granted. */ function getConsentUrl(requestId: string): string { - const frontendUrl = system - .getOrThrow(SharedSystemProp.FRONTEND_URL) - .replace(/\/+$/, ''); + const frontendUrl = stripTrailingSlashes( + system.getOrThrow(SharedSystemProp.FRONTEND_URL), + ); return `${frontendUrl}/settings/connected-apps?request_id=${encodeURIComponent( requestId, diff --git a/packages/server/api/src/app/oauth/resource-registry.ts b/packages/server/api/src/app/oauth/resource-registry.ts index 4ed37665dd..68ade92904 100644 --- a/packages/server/api/src/app/oauth/resource-registry.ts +++ b/packages/server/api/src/app/oauth/resource-registry.ts @@ -1,3 +1,4 @@ +import { stripTrailingSlashes } from './canonical-url'; import { oauthConfig } from './oauth-config'; export type ResourceId = 'api' | 'mcp'; @@ -49,7 +50,7 @@ export function resolveResource( return undefined; } - const normalized = resource.replace(/\/+$/, ''); + const normalized = stripTrailingSlashes(resource); return getRegisteredResources().find((r) => r.canonicalUri === normalized); } 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 index 5bcd1d4b78..875596ca02 100644 --- a/packages/server/api/test/unit/oauth/clients.service.test.ts +++ b/packages/server/api/test/unit/oauth/clients.service.test.ts @@ -75,7 +75,7 @@ describe('clientsService', () => { const response = await clientsService.registerClient(validMetadata()); expect(response.client_id).toEqual(expect.any(String)); - expect(response.client_id.length).toBe(21); + expect(response.client_id).toHaveLength(21); expect(response.client_name).toBe('Test MCP Client'); expect(response.redirect_uris).toEqual([ 'https://client.example.com/callback', diff --git a/tools/oauth-flow.sh b/tools/oauth-flow.sh index a36948c775..c8874e8b78 100755 --- a/tools/oauth-flow.sh +++ b/tools/oauth-flow.sh @@ -29,16 +29,20 @@ 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))" "$1" +print(json.dumps(shown, indent=2))" "$jwt" } -json_get() { python3 -c "import json,sys;print(json.load(open(sys.argv[1]))[sys.argv[2]])" "$1" "$2"; } +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" @@ -48,9 +52,9 @@ if ! curl -sf -o /dev/null "$API/.well-known/oauth-authorization-server"; then fi echo " API up, OAuth enabled" -if [ "$RESOURCE_KIND" = "mcp" ]; then +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)" + [[ -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 @@ -83,7 +87,7 @@ AUTHORIZE_URL="$API/v1/oauth/authorize?client_id=$CLIENT_ID&redirect_uri=$( 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" +[[ -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)" @@ -116,7 +120,7 @@ echo " claims in the client's token:" claims "$ACCESS_TOKEN" | sed 's/^/ /' # ------------------------------------------------------- use it on the API --- -if [ "$RESOURCE_KIND" = "mcp" ]; then +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:" @@ -140,7 +144,7 @@ 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" +[[ "$STATUS" == "200" ]] || fail "the token was refused by the API" # ------------------------------------------------------------------ refresh --- say "8. Refresh, and confirm the old token is single-use" From c00f09c3f2b00d0ebd90404ee6bf779491f6f7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Thu, 30 Jul 2026 11:09:24 +0100 Subject: [PATCH 25/25] Bound the grant caches and validate the OAuth TTLs at boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from reviewing this PR as a whole. The grant caches grew for the life of the process. Both maps are keyed by grant id, and nothing evicted an entry once its window passed — a stale one was overwritten on the next read, so the key stayed. Reconnecting an agent creates a new grant by design, so a fleet that reconnects on a schedule left a key behind every time. Inserts now sweep expired entries once a map is larger than any real working set, and clear it outright if the sweep frees nothing: both caches are optimizations, so bounding memory costs at most one query or one lastUsedAt write. The TTLs were required to be numbers but not to be sensible. An access-token TTL of a month would boot a healthy-looking server whose revocation latency was a month, since a self-contained token is only re-checked when it expires. That is the failure mode the rest of validateOAuthConfiguration exists to prevent, so the TTLs now have ranges there, and a test asserts the shipped defaults sit inside them — a range that excluded them would fail every boot while the other assertions still looked right. Both fixes were mutation-tested: reverting the sweep fails 2 tests, neutering the range check fails 7. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/src/app/oauth/grants.service.ts | 53 ++++++++++++++++++- .../src/app/oauth/oauth-config-validation.ts | 51 ++++++++++++++++++ .../test/unit/oauth/grants.service.test.ts | 35 ++++++++++++ .../oauth/oauth-config-validation.test.ts | 49 +++++++++++++++++ 4 files changed, 186 insertions(+), 2 deletions(-) diff --git a/packages/server/api/src/app/oauth/grants.service.ts b/packages/server/api/src/app/oauth/grants.service.ts index 6af499f302..3f038ad677 100644 --- a/packages/server/api/src/app/oauth/grants.service.ts +++ b/packages/server/api/src/app/oauth/grants.service.ts @@ -42,6 +42,41 @@ 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, @@ -98,7 +133,12 @@ export const grantsService = { const grant = await grantRepo().findOneBy({ id: grantId }); const snapshot = grant ? toSnapshot(grant) : undefined; - snapshotCache.set(grantId, { snapshot, fetchedAt: Date.now() }); + remember( + snapshotCache, + grantId, + { snapshot, fetchedAt: Date.now() }, + (entry) => Date.now() - entry.fetchedAt >= GRANT_SNAPSHOT_CACHE_TTL_MS, + ); return snapshot; }, @@ -168,7 +208,12 @@ export const grantsService = { return; } - lastUsedWrittenAt.set(grantId, now); + remember( + lastUsedWrittenAt, + grantId, + now, + (writtenAtEntry) => now - writtenAtEntry >= LAST_USED_WRITE_INTERVAL_MS, + ); await grantRepo().update( { id: grantId }, { lastUsedAt: new Date(now).toISOString() }, @@ -179,4 +224,8 @@ export const grantsService = { snapshotCache.clear(); lastUsedWrittenAt.clear(); }, + + snapshotCacheSizeForTests(): number { + return snapshotCache.size; + }, }; diff --git a/packages/server/api/src/app/oauth/oauth-config-validation.ts b/packages/server/api/src/app/oauth/oauth-config-validation.ts index 38a2969f88..e99399fe81 100644 --- a/packages/server/api/src/app/oauth/oauth-config-validation.ts +++ b/packages/server/api/src/app/oauth/oauth-config-validation.ts @@ -28,6 +28,28 @@ function canonicalize(audience: string): string { } } +/** + * 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; @@ -65,6 +87,35 @@ export function validateOAuthConfiguration(): void { 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); diff --git a/packages/server/api/test/unit/oauth/grants.service.test.ts b/packages/server/api/test/unit/oauth/grants.service.test.ts index 6dec83ab62..6e4dac34c7 100644 --- a/packages/server/api/test/unit/oauth/grants.service.test.ts +++ b/packages/server/api/test/unit/oauth/grants.service.test.ts @@ -240,6 +240,41 @@ describe('grantsService', () => { }); }); + 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); 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 index f0884f7ff5..a4863fd807 100644 --- a/packages/server/api/test/unit/oauth/oauth-config-validation.test.ts +++ b/packages/server/api/test/unit/oauth/oauth-config-validation.test.ts @@ -77,6 +77,55 @@ describe('validateOAuthConfiguration', () => { 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,