diff --git a/.env.template b/.env.template index da18c853cd..cf3072786a 100644 --- a/.env.template +++ b/.env.template @@ -81,3 +81,13 @@ CHROMATIC_PROJECT_TOKEN=chpt_sample_secret # THEME OPS_DARK_THEME_ENABLED=false OPS_CODE_BLOCK_MEMORY_LIMIT_IN_MB=256 + +# EXTERNAL OAUTH +OPS_OAUTH_ENABLED=false +OPS_OAUTH_ISSUER_URL=http://localhost:3000 +OPS_MCP_RESOURCE_URL= +OPS_OAUTH_RS_CLIENT_SECRET= +OPS_OAUTH_REFRESH_TOKEN_TTL_DAYS=30 +OPS_OAUTH_ACCESS_TOKEN_TTL_SECONDS=900 +OPS_OAUTH_EXCHANGE_TOKEN_TTL_SECONDS=300 +OPS_OAUTH_SIGNING_KEY_PEM_PATH= diff --git a/.gitignore b/.gitignore index a30eebbccd..f97f6f838e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ node_modules /tmp /.nx /cache +**/cache/codes/ /packages/ui-components/storybook-static diff --git a/packages/server/api/src/app/app.ts b/packages/server/api/src/app/app.ts index fd677eeee5..dd9446d168 100644 --- a/packages/server/api/src/app/app.ts +++ b/packages/server/api/src/app/app.ts @@ -53,6 +53,9 @@ import { formModule } from './flows/flow/form/form.module'; import { folderModule } from './flows/folder/folder.module'; import { triggerEventModule } from './flows/trigger-events/trigger-event.module'; import { systemJobsSchedule } from './helper/system-jobs'; +import { oauthConfig } from './oauth/config/oauth-config'; +import { registerOAuthCleanupHandler } from './oauth/oauth-cleanup-job'; +import { oauthModule } from './oauth/oauth.module'; import { organizationModule } from './organization/organization.module'; import { projectModule } from './project/project-module'; import { slackInteractionModule } from './slack/slack-interaction-module'; @@ -225,6 +228,14 @@ export const setupApp = async ( await app.register(blockVariableModule); await app.register(benchmarkModule); + // Unconditional: the schedule lives in Redis and survives OAuth being turned off, so + // the handler must exist even then. It no-ops while disabled. + registerOAuthCleanupHandler(); + + if (oauthConfig.isEnabled()) { + await app.register(oauthModule); + } + app.get( '/redirect', async ( diff --git a/packages/server/api/src/app/authentication/context/access-token-manager.ts b/packages/server/api/src/app/authentication/context/access-token-manager.ts index c5b0c1c156..4ac1e661ec 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 { OAuthError } from '../../oauth/common/oauth-errors'; +import { oauthConfig } from '../../oauth/config/oauth-config'; +import { buildOAuthServicePrincipal } from '../../oauth/projects/service-principal'; +import { OAuthAccessTokenClaims } from '../../oauth/storage/oauth-model'; +import { signingKeyService } from '../../oauth/tokens/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,51 @@ export const accessTokenManager = { }, }; +function isOAuthIssuedToken(token: string): boolean { + return ( + jwtLibrary.decode(token, { complete: true })?.header?.alg === + JwtSignAlgorithm.RS256 + ); +} + +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. Reporting a server-side + // failure as an invalid credential would have clients discard their refresh token and + // re-authorize, turning a brief outage 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..e64b7ca85c 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,22 @@ export class AccessTokenAuthnHandler extends BaseSecurityHandler { return Promise.resolve(hasToken || !publicRoute); } + /** + * An explicit `Authorization` header wins over the session cookie: a caller presenting a + * bearer token states which identity it wants, and preferring an ambient cookie would + * authenticate it as somebody else. + */ private getAccessToken(request: FastifyRequest): string | undefined { - const cookieToken = request.cookies?.[AccessTokenAuthnHandler.COOKIE_NAME]; - if (!isNil(cookieToken)) { - return cookieToken; - } - const header = request.headers[AccessTokenAuthnHandler.HEADER_NAME]; if (header?.startsWith(AccessTokenAuthnHandler.HEADER_PREFIX)) { return header.substring(AccessTokenAuthnHandler.HEADER_PREFIX.length); } + const cookieToken = request.cookies?.[AccessTokenAuthnHandler.COOKIE_NAME]; + if (!isNil(cookieToken)) { + return cookieToken; + } + return undefined; } diff --git a/packages/server/api/src/app/database/database-connection.ts b/packages/server/api/src/app/database/database-connection.ts index 5b53de6e87..d449fea4ca 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/storage/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/1786355547856-CreateOAuthTables.ts b/packages/server/api/src/app/database/migrations/1786355547856-CreateOAuthTables.ts new file mode 100644 index 0000000000..5482c78e10 --- /dev/null +++ b/packages/server/api/src/app/database/migrations/1786355547856-CreateOAuthTables.ts @@ -0,0 +1,171 @@ +import { logger } from '@openops/server-shared'; +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateOAuthTables1786355547856 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + logger.info('CreateOAuthTables1786355547856: starting'); + + await queryRunner.query(` + CREATE TABLE "oauth_signing_key" ( + "id" varchar(21) NOT NULL, + "created" timestamp with time zone DEFAULT now() NOT NULL, + "updated" timestamp with time zone DEFAULT now() NOT NULL, + "privateKeyEncrypted" text NOT NULL, + "publicKeyPem" text NOT NULL, + "status" varchar(16) NOT NULL, + CONSTRAINT "PK_oauth_signing_key" PRIMARY KEY ("id") + ); + `); + + // Guarantees concurrently booting replicas converge on a single active key. + await queryRunner.query(` + CREATE UNIQUE INDEX "idx_oauth_signing_key_single_active" + ON "oauth_signing_key" ("status") WHERE "status" = 'active'; + `); + + await queryRunner.query(` + CREATE TABLE "oauth_client" ( + "id" varchar(21) NOT NULL, + "created" timestamp with time zone DEFAULT now() NOT NULL, + "updated" timestamp with time zone DEFAULT now() NOT NULL, + "clientName" varchar(128) NOT NULL, + "redirectUris" jsonb NOT NULL, + "grantTypes" jsonb NOT NULL, + "tokenEndpointAuthMethod" varchar(32) NOT NULL, + "clientSecretHash" varchar(64), + CONSTRAINT "PK_oauth_client" PRIMARY KEY ("id") + ); + `); + + await queryRunner.query(` + CREATE TABLE "oauth_grant" ( + "id" varchar(21) NOT NULL, + "created" timestamp with time zone DEFAULT now() NOT NULL, + "updated" timestamp with time zone DEFAULT now() NOT NULL, + "clientId" varchar(21) NOT NULL, + "userId" varchar(21) NOT NULL, + "resourceId" varchar(32) NOT NULL, + "status" varchar(16) NOT NULL, + "lastUsedAt" timestamp with time zone, + "revokedAt" timestamp with time zone, + CONSTRAINT "PK_oauth_grant" PRIMARY KEY ("id"), + CONSTRAINT "fk_oauth_grant_client" FOREIGN KEY ("clientId") + REFERENCES "oauth_client" ("id") ON DELETE CASCADE, + CONSTRAINT "fk_oauth_grant_user" FOREIGN KEY ("userId") + REFERENCES "user" ("id") ON DELETE CASCADE + ); + `); + + // Not unique: a user may hold several connections for the same client. + await queryRunner.query(` + CREATE INDEX "idx_oauth_grant_client_id_user_id" + ON "oauth_grant" ("clientId", "userId"); + `); + + await queryRunner.query(` + CREATE INDEX "idx_oauth_grant_user_id" ON "oauth_grant" ("userId"); + `); + + await queryRunner.query(` + CREATE TABLE "oauth_pending_authorization" ( + "id" varchar(21) NOT NULL, + "created" timestamp with time zone DEFAULT now() NOT NULL, + "updated" timestamp with time zone DEFAULT now() NOT NULL, + "clientId" varchar(21) NOT NULL, + "redirectUri" varchar(512) NOT NULL, + "codeChallenge" varchar(43) NOT NULL, + "resource" varchar(512) NOT NULL, + "scope" varchar(128) NOT NULL, + "state" text, + "expiresAt" timestamp with time zone NOT NULL, + "consumedAt" timestamp with time zone, + CONSTRAINT "PK_oauth_pending_authorization" PRIMARY KEY ("id"), + CONSTRAINT "fk_oauth_pending_authorization_client" FOREIGN KEY ("clientId") + REFERENCES "oauth_client" ("id") ON DELETE CASCADE + ); + `); + + await queryRunner.query(` + CREATE INDEX "idx_oauth_pending_authorization_expires_at" + ON "oauth_pending_authorization" ("expiresAt"); + `); + + await queryRunner.query(` + CREATE TABLE "oauth_authorization_code" ( + "id" varchar(21) NOT NULL, + "created" timestamp with time zone DEFAULT now() NOT NULL, + "updated" timestamp with time zone DEFAULT now() NOT NULL, + "codeHash" varchar(64) NOT NULL, + "clientId" varchar(21) NOT NULL, + "userId" varchar(21) NOT NULL, + "redirectUri" varchar(512) NOT NULL, + "codeChallenge" varchar(43) NOT NULL, + "resource" varchar(512) NOT NULL, + "scope" varchar(128) NOT NULL, + "expiresAt" timestamp with time zone NOT NULL, + "consumedAt" timestamp with time zone, + CONSTRAINT "PK_oauth_authorization_code" PRIMARY KEY ("id"), + CONSTRAINT "fk_oauth_authorization_code_client" FOREIGN KEY ("clientId") + REFERENCES "oauth_client" ("id") ON DELETE CASCADE + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX "idx_oauth_authorization_code_code_hash" + ON "oauth_authorization_code" ("codeHash"); + `); + + await queryRunner.query(` + CREATE INDEX "idx_oauth_authorization_code_expires_at" + ON "oauth_authorization_code" ("expiresAt"); + `); + + await queryRunner.query(` + CREATE TABLE "oauth_refresh_token" ( + "id" varchar(21) NOT NULL, + "created" timestamp with time zone DEFAULT now() NOT NULL, + "updated" timestamp with time zone DEFAULT now() NOT NULL, + "tokenHash" varchar(64) NOT NULL, + "grantId" varchar(21) NOT NULL, + "familyId" varchar(21) NOT NULL, + "clientId" varchar(21) NOT NULL, + "resource" varchar(512) NOT NULL, + "scope" varchar(128) NOT NULL, + "projectId" varchar(21) NOT NULL, + "expiresAt" timestamp with time zone NOT NULL, + "revokedAt" timestamp with time zone, + CONSTRAINT "PK_oauth_refresh_token" PRIMARY KEY ("id"), + CONSTRAINT "fk_oauth_refresh_token_grant" FOREIGN KEY ("grantId") + REFERENCES "oauth_grant" ("id") ON DELETE CASCADE, + CONSTRAINT "fk_oauth_refresh_token_client" FOREIGN KEY ("clientId") + REFERENCES "oauth_client" ("id") ON DELETE CASCADE + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX "idx_oauth_refresh_token_token_hash" + ON "oauth_refresh_token" ("tokenHash"); + `); + + await queryRunner.query(` + CREATE INDEX "idx_oauth_refresh_token_grant_id" + ON "oauth_refresh_token" ("grantId"); + `); + + await queryRunner.query(` + CREATE INDEX "idx_oauth_refresh_token_family_id" + ON "oauth_refresh_token" ("familyId"); + `); + + await queryRunner.query(` + CREATE INDEX "idx_oauth_refresh_token_expires_at" + ON "oauth_refresh_token" ("expiresAt"); + `); + + logger.info('CreateOAuthTables1786355547856: completed'); + } + + public async down(queryRunner: QueryRunner): Promise { + throw new Error('Rollback not implemented'); + } +} diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index be7f6eb346..7f4ed63d5c 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 { CreateOAuthTables1786355547856 } from './migrations/1786355547856-CreateOAuthTables'; const getSslConfig = (): boolean | TlsOptions => { const useSsl = system.get(AppSystemProp.POSTGRES_USE_SSL); @@ -90,6 +91,7 @@ const getMigrations = (): (new () => MigrationInterface)[] => { DropLastRunIdFromBenchmark1772449919844, AddIsCleanupToBenchmarkFlow1773046640936, FixFolderUniqueConstraint1776097737024, + CreateOAuthTables1786355547856, ]; }; diff --git a/packages/server/api/src/app/flags/flag.service.ts b/packages/server/api/src/app/flags/flag.service.ts index f17e8baec1..cfa023a3ac 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/config/oauth-config'; import { devFlagsService } from './dev-flags.service'; import { FlagEntity } from './flag.entity'; import { defaultTheme } from './theme'; @@ -277,6 +278,12 @@ export const flagService = { created, updated, }, + { + id: FlagId.CONNECTED_APPS_ENABLED, + value: oauthConfig.isEnabled(), + created, + updated, + }, { id: FlagId.THIRD_PARTY_AUTH_PROVIDER_REDIRECT_URL, value: await this.getBackendRedirectUrl(), diff --git a/packages/server/api/src/app/helper/system-jobs/common.ts b/packages/server/api/src/app/helper/system-jobs/common.ts index 954560b3c6..c41affd430 100644 --- a/packages/server/api/src/app/helper/system-jobs/common.ts +++ b/packages/server/api/src/app/helper/system-jobs/common.ts @@ -15,6 +15,7 @@ export enum SystemJobName { CREATE_TEMPLATE_TABLES = 'create-template-tables', CAMPAIGN_COMPLETION = 'campaign-completion', CONNECTION_VALIDATION = 'connection-validation', + OAUTH_CLEANUP = 'oauth-cleanup', } type HardDeleteProjectSystemJobData = { @@ -44,6 +45,7 @@ type SystemJobDataMap = { [SystemJobName.LOGS_CLEANUP_TRIGGER]: Record; [SystemJobName.CREATE_TEMPLATE_TABLES]: TablesServerContext; [SystemJobName.CONNECTION_VALIDATION]: undefined; + [SystemJobName.OAUTH_CLEANUP]: Record; }; export type SystemJobData = diff --git a/packages/server/api/src/app/oauth/authorization/authorize-validation.ts b/packages/server/api/src/app/oauth/authorization/authorize-validation.ts new file mode 100644 index 0000000000..3d7990bbb3 --- /dev/null +++ b/packages/server/api/src/app/oauth/authorization/authorize-validation.ts @@ -0,0 +1,209 @@ +import { invalidRequest } from '../common/oauth-errors'; +import { + RegisteredResource, + resolveResource, +} from '../discovery/resource-registry'; +import { OAuthClient } from '../storage/oauth-model'; +import { isValidCodeChallenge } from './pkce'; +import { matchesRegisteredRedirectUri } from './redirect-uri'; + +// `qs` turns `state[x]=1` into an object, so every field is read through `readParam` +// rather than assumed to be a string. +export type AuthorizeQuery = Record; + +export type OAuthRequestBody = Record; + +// `state` is opaque client data that must round-trip byte for byte, so it is capped only +// to bound how much a single request can write. +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 non-string value is malformed input, not an omission: defaulting it would give the +// client something other than what it asked for. +function findMalformedParam(query: AuthorizeQuery): string | undefined { + return Object.keys(query).find( + (name) => query[name] !== undefined && typeof query[name] !== 'string', + ); +} + +export type AuthorizeValidationResult = + | { kind: 'render_error'; error: string; description: string } + | { + 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; + }; + +/** + * Callers must not redirect for a `render_error`: the supplied redirect target is + * untrusted there, so following it would make this endpoint 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, + }; + } + + 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: 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, + }; +} + +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/authorization/pending-authorization.service.ts b/packages/server/api/src/app/oauth/authorization/pending-authorization.service.ts new file mode 100644 index 0000000000..51e762fd54 --- /dev/null +++ b/packages/server/api/src/app/oauth/authorization/pending-authorization.service.ts @@ -0,0 +1,106 @@ +import { openOpsId } from '@openops/shared'; +import { IsNull } from 'typeorm'; +import { repoFactory } from '../../core/db/repo-factory'; +import { invalidRequest } from '../common/oauth-errors'; +import { OAuthPendingAuthorization } from '../storage/oauth-model'; +import { earlierThan } from '../storage/oauth-query'; +import { OAuthPendingAuthorizationEntity } from '../storage/oauth.entity'; + +const repo = repoFactory( + OAuthPendingAuthorizationEntity, +); + +// RFC 6749 §4.1.1 gives no bound: 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 all report this same text, so the endpoint is not +// 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` already validated, so the browser cannot re-supply + * — and tamper with — any of them. The returned id is the only handle it carries. + */ + 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; + }, + + async get(id: string): Promise { + const record = await repo().findOneBy({ id }); + + if (!record) { + throw invalidRequest(UNUSABLE_REQUEST); + } + + if (record.consumedAt !== null || isExpired(record, Date.now())) { + throw invalidRequest(UNUSABLE_REQUEST); + } + + return record; + }, + + /** + * The conditional update is the single-use guarantee: concurrent submissions race on + * `consumedAt IS NULL` in the database, so only one can mint an authorization code. + */ + async consume(id: string): Promise { + const consumedAt = new Date().toISOString(); + // Some drivers report `affected` as null rather than 0. + 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; + }, + + 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/authorization/pkce.ts b/packages/server/api/src/app/oauth/authorization/pkce.ts new file mode 100644 index 0000000000..1686f5f152 --- /dev/null +++ b/packages/server/api/src/app/oauth/authorization/pkce.ts @@ -0,0 +1,27 @@ +import crypto from 'node:crypto'; +import { timingSafeStringEqual } from '../common/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/authorization/redirect-uri.ts b/packages/server/api/src/app/oauth/authorization/redirect-uri.ts new file mode 100644 index 0000000000..82be1bcff8 --- /dev/null +++ b/packages/server/api/src/app/oauth/authorization/redirect-uri.ts @@ -0,0 +1,82 @@ +// `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); +} + +/** + * https only, plus http loopback for native clients (RFC 8252 §7.3). Fragments are + * forbidden by RFC 6749 §3.1.2; userinfo is rejected because this value is echoed into a + * `Location` header; the length cap keeps it 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) { + return false; + } + + if (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 { + // Re-checked: loopback matching ignores the port, so a presented URI could otherwise + // carry a fragment, userinfo or unbounded length past the registration checks. + 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/clients/clients.service.ts b/packages/server/api/src/app/oauth/clients/clients.service.ts new file mode 100644 index 0000000000..8024a1e0f9 --- /dev/null +++ b/packages/server/api/src/app/oauth/clients/clients.service.ts @@ -0,0 +1,323 @@ +import { AppSystemProp, logger } from '@openops/server-shared'; +import { ApplicationError, ErrorCode, openOpsId } from '@openops/shared'; +import { repoFactory } from '../../core/db/repo-factory'; +import { isRegistrableRedirectUri } from '../authorization/redirect-uri'; +import { sha256Hex, timingSafeStringEqual } from '../common/oauth-crypto'; +import { + invalidClient, + invalidClientMetadata, + invalidRedirectUri, + unauthorizedClient, +} from '../common/oauth-errors'; +import { oauthConfig } from '../config/oauth-config'; +import { + OAuthClient, + OAuthTokenEndpointAuthMethod, +} from '../storage/oauth-model'; +import { OAuthClientEntity } from '../storage/oauth.entity'; + +const repo = repoFactory(OAuthClientEntity); + +// Also the `client_id` the resource server sends, so it must fit the 21-char id column. +export const RS_CLIENT_ID = 'openops-mcp-rs'; +export const TOKEN_EXCHANGE_GRANT = + 'urn:ietf:params:oauth:grant-type:token-exchange'; + +const RS_CLIENT_NAME = 'OpenOps MCP Resource Server'; +const RS_CLIENT_SECRET_MIN_LENGTH = 32; +const UNIQUE_VIOLATION = '23505'; + +const UNMATCHABLE_HASH = '-'.repeat(64); + +// Anyone on the network can register, so no grant that skips user consent belongs here. +const REGISTRABLE_GRANT_TYPES = ['authorization_code', 'refresh_token']; + +const MAX_CLIENT_NAME_LENGTH = 128; +const MAX_REDIRECT_URIS = 10; + +export type RegisteredClientResponse = { + client_id: string; + client_name: string; + redirect_uris: string[]; + grant_types: string[]; + token_endpoint_auth_method: OAuthTokenEndpointAuthMethod; + client_id_issued_at: number; +}; + +type ClientRegistrationMetadata = { + clientName: string; + redirectUris: string[]; + grantTypes: string[]; +}; + +function parseClientName(value: unknown): string { + if (typeof value !== 'string' || value.length === 0) { + throw invalidClientMetadata('client_name is required'); + } + + if (value.length > MAX_CLIENT_NAME_LENGTH) { + throw invalidClientMetadata( + `client_name must be at most ${MAX_CLIENT_NAME_LENGTH} characters`, + ); + } + + return value; +} + +function parseRedirectUris(value: unknown): string[] { + if (!Array.isArray(value) || value.length === 0) { + throw invalidRedirectUri('redirect_uris must contain at least one entry'); + } + + if (value.length > MAX_REDIRECT_URIS) { + throw invalidRedirectUri( + `redirect_uris must contain at most ${MAX_REDIRECT_URIS} entries`, + ); + } + + for (const uri of value) { + if (typeof uri !== 'string' || !isRegistrableRedirectUri(uri)) { + throw invalidRedirectUri( + 'redirect_uris must be https URIs or http loopback URIs without a fragment', + ); + } + } + + return value as string[]; +} + +function parseGrantTypes(value: unknown): string[] { + if (value === undefined) { + return [...REGISTRABLE_GRANT_TYPES]; + } + + if (!Array.isArray(value) || value.length === 0) { + throw invalidClientMetadata('grant_types must be a non-empty array'); + } + + for (const grantType of value) { + if ( + typeof grantType !== 'string' || + !REGISTRABLE_GRANT_TYPES.includes(grantType) + ) { + throw invalidClientMetadata( + `grant_types may only contain ${REGISTRABLE_GRANT_TYPES.join(', ')}`, + ); + } + } + + return value as string[]; +} + +function assertPublicAuthMethod(value: unknown): void { + if (value !== undefined && value !== 'none') { + throw invalidClientMetadata( + 'token_endpoint_auth_method must be "none"; registered clients must use PKCE', + ); + } +} + +function parseRegistrationMetadata(body: unknown): ClientRegistrationMetadata { + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + throw invalidClientMetadata('client metadata must be a JSON object'); + } + + const metadata = body as Record; + assertPublicAuthMethod(metadata['token_endpoint_auth_method']); + + return { + clientName: parseClientName(metadata['client_name']), + redirectUris: parseRedirectUris(metadata['redirect_uris']), + grantTypes: parseGrantTypes(metadata['grant_types']), + }; +} + +// RFC 6749 §2.3.1 requires form-urlencoded halves, but clients commonly skip it, so a +// malformed escape falls back to the raw value. +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 = { + async registerClient(body: unknown): Promise { + const metadata = parseRegistrationMetadata(body); + const now = new Date().toISOString(); + + const client: OAuthClient = { + id: openOpsId(), + created: now, + updated: now, + clientName: metadata.clientName, + redirectUris: metadata.redirectUris, + grantTypes: metadata.grantTypes, + tokenEndpointAuthMethod: 'none', + clientSecretHash: null, + }; + + await repo().save(client); + logger.info('OAuth client registered', { + clientId: client.id, + clientName: client.clientName, + }); + + return { + client_id: client.id, + client_name: client.clientName, + redirect_uris: client.redirectUris, + grant_types: client.grantTypes, + token_endpoint_auth_method: client.tokenEndpointAuthMethod, + client_id_issued_at: Math.floor( + new Date(client.created).getTime() / 1000, + ), + }; + }, + + async getClient(clientId: string): Promise { + return repo().findOneBy({ id: clientId }); + }, + + async getClientOrThrow(clientId: string): Promise { + const client = await clientsService.getClient(clientId); + + if (!client) { + throw invalidClient('unknown client'); + } + + return client; + }, + + assertGrantTypeAllowed(client: OAuthClient, grantType: string): void { + if (!client.grantTypes.includes(grantType)) { + throw unauthorizedClient( + `client is not authorized to use grant type ${grantType}`, + ); + } + }, + + // RFC 6749 §2.3.1. Every failure returns the same description, so client ids cannot + // be enumerated. + 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; + + // Run 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; + }, + + // Optional: an install with no hosted resource server configures no secret and gets + // no such client. + async ensureResourceServerClient(): Promise { + const secret = oauthConfig.getResourceServerClientSecret(); + + if (!secret) { + return; + } + + // A configuration fault, not an OAuth protocol response: fail at boot rather than + // run with a brute-forceable shared secret. + if (secret.length < RS_CLIENT_SECRET_MIN_LENGTH) { + throw new ApplicationError( + { + code: ErrorCode.SYSTEM_PROP_INVALID, + params: { prop: AppSystemProp.OAUTH_RS_CLIENT_SECRET }, + }, + `OPS_${AppSystemProp.OAUTH_RS_CLIENT_SECRET} must be at least ${RS_CLIENT_SECRET_MIN_LENGTH} characters`, + ); + } + + const secretHash = sha256Hex(secret); + const existing = await repo().findOneBy({ id: RS_CLIENT_ID }); + const now = new Date().toISOString(); + + if (existing) { + if (existing.clientSecretHash !== secretHash) { + await repo().update( + { id: RS_CLIENT_ID }, + { clientSecretHash: secretHash, updated: now }, + ); + logger.info('OAuth resource server client secret rotated'); + } + + return; + } + + try { + await repo().insert({ + id: RS_CLIENT_ID, + created: now, + updated: now, + clientName: RS_CLIENT_NAME, + redirectUris: [], + grantTypes: [TOKEN_EXCHANGE_GRANT], + tokenEndpointAuthMethod: 'client_secret_basic', + clientSecretHash: secretHash, + }); + logger.info('OAuth resource server client created'); + } catch (error) { + // A replica booting at the same time inserted an equivalent row; adopt it. + 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/clients/grants.service.ts b/packages/server/api/src/app/oauth/clients/grants.service.ts new file mode 100644 index 0000000000..c24b396386 --- /dev/null +++ b/packages/server/api/src/app/oauth/clients/grants.service.ts @@ -0,0 +1,212 @@ +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 '../common/oauth-errors'; +import { OAuthGrant, OAuthRefreshToken } from '../storage/oauth-model'; +import { + OAuthGrantEntity, + OAuthRefreshTokenEntity, +} from '../storage/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. Refresh rotation, token exchange and request + * authentication all consult it, so revoking the row kills that connection alone. + */ +export type GrantSnapshot = { + id: string; + userId: string; + clientId: string; + status: OAuthGrant['status']; +}; + +type CachedSnapshot = { + snapshot: GrantSnapshot | undefined; + fetchedAt: number; +}; + +// Access tokens are self-contained, so the grant is re-checked per request; the cache +// keeps that off the hot path and bounds revocation latency to the TTL. +const snapshotCache = new Map(); + +const lastUsedWrittenAt = new Map(); + +// Both maps are keyed by grant id and nothing evicts a key once its window passes, so +// reconnecting agents would grow them for the life of the process. Both are pure +// optimizations, so sweeping only past any real working set is enough. +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; bound the memory rather than the + // query count, since correctness does not depend on them. + if (cache.size >= CACHE_SWEEP_THRESHOLD) { + cache.clear(); + } + } + + cache.set(key, value); +} + +function toSnapshot(grant: OAuthGrant): GrantSnapshot { + return { + id: grant.id, + userId: grant.userId, + clientId: grant.clientId, + status: grant.status, + }; +} + +function invalidateSnapshot(grantId: string): void { + snapshotCache.delete(grantId); +} + +export type CreateGrantParams = { + clientId: string; + userId: string; + resourceId: string; +}; + +export const grantsService = { + /** + * One grant per completed authorization, so the same agent can be connected more than + * once. Called at code redemption, not at consent: an authorization the client never + * completed is not a connection. + */ + async create(params: CreateGrantParams): Promise { + const now = new Date().toISOString(); + + const grant: OAuthGrant = { + id: openOpsId(), + created: now, + updated: now, + clientId: params.clientId, + userId: params.userId, + resourceId: params.resourceId, + status: 'active', + lastUsedAt: null, + revokedAt: null, + }; + + await grantRepo().insert(grant); + + return grant; + }, + + async getGrantSnapshot(grantId: string): Promise { + const cached = snapshotCache.get(grantId); + if (cached && Date.now() - cached.fetchedAt < GRANT_SNAPSHOT_CACHE_TTL_MS) { + return cached.snapshot; + } + + const grant = await grantRepo().findOneBy({ id: grantId }); + const snapshot = grant ? toSnapshot(grant) : undefined; + remember( + snapshotCache, + grantId, + { snapshot, fetchedAt: Date.now() }, + (entry) => Date.now() - entry.fetchedAt >= GRANT_SNAPSHOT_CACHE_TTL_MS, + ); + + return snapshot; + }, + + async getActiveGrantOrThrow(grantId: string): Promise { + const snapshot = await grantsService.getGrantSnapshot(grantId); + + if (snapshot?.status !== 'active') { + throw invalidGrant('the authorization for this client has been revoked'); + } + + return snapshot; + }, + + /** + * Without the token cascade the client could keep minting access tokens by refreshing. + * Other connections for 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' }, + }); + }, + + /** Throttled: it would otherwise write on every API call made through a connection. */ + async touch(grantId: string): Promise { + const now = Date.now(); + const writtenAt = lastUsedWrittenAt.get(grantId); + + if ( + writtenAt !== undefined && + now - writtenAt < LAST_USED_WRITE_INTERVAL_MS + ) { + return; + } + + remember( + lastUsedWrittenAt, + grantId, + now, + (writtenAtEntry) => now - writtenAtEntry >= LAST_USED_WRITE_INTERVAL_MS, + ); + await grantRepo().update( + { id: grantId }, + { lastUsedAt: new Date(now).toISOString() }, + ); + }, + + clearSnapshotCacheForTests(): void { + snapshotCache.clear(); + lastUsedWrittenAt.clear(); + }, + + snapshotCacheSizeForTests(): number { + return snapshotCache.size; + }, +}; diff --git a/packages/server/api/src/app/oauth/common/canonical-url.ts b/packages/server/api/src/app/oauth/common/canonical-url.ts new file mode 100644 index 0000000000..ed436d5294 --- /dev/null +++ b/packages/server/api/src/app/oauth/common/canonical-url.ts @@ -0,0 +1,11 @@ +// Scanned backwards rather than with a regex: `/\/+$/` backtracks quadratically, and +// one caller normalizes a client-supplied value on public endpoints. +export function stripTrailingSlashes(value: string): string { + let end = value.length; + + while (end > 0 && value[end - 1] === '/') { + end -= 1; + } + + return end === value.length ? value : value.slice(0, end); +} diff --git a/packages/server/api/src/app/oauth/common/oauth-crypto.ts b/packages/server/api/src/app/oauth/common/oauth-crypto.ts new file mode 100644 index 0000000000..31b513ac09 --- /dev/null +++ b/packages/server/api/src/app/oauth/common/oauth-crypto.ts @@ -0,0 +1,19 @@ +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'); +} + +// Hashed first 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/common/oauth-errors.ts b/packages/server/api/src/app/oauth/common/oauth-errors.ts new file mode 100644 index 0000000000..f664bceceb --- /dev/null +++ b/packages/server/api/src/app/oauth/common/oauth-errors.ts @@ -0,0 +1,43 @@ +// RFC 6749 §5.2 error responses. OAuth clients branch on the `error` code, which the +// `ApplicationError` envelope does not carry. +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/config/oauth-config-validation.ts b/packages/server/api/src/app/oauth/config/oauth-config-validation.ts new file mode 100644 index 0000000000..a8b68ce41f --- /dev/null +++ b/packages/server/api/src/app/oauth/config/oauth-config-validation.ts @@ -0,0 +1,125 @@ +import { AppSystemProp, DatabaseType, system } from '@openops/server-shared'; +import { ApplicationError, ErrorCode } from '@openops/shared'; +import { stripTrailingSlashes } from '../common/canonical-url'; +import { getRegisteredResources } from '../discovery/resource-registry'; +import { oauthConfig } from './oauth-config'; + +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); + +function invalidProp(prop: string, message: string): ApplicationError { + return new ApplicationError( + { code: ErrorCode.SYSTEM_PROP_INVALID, params: { prop } }, + `OPS_${prop} ${message}`, + ); +} + +// Scheme and host are case-insensitive per RFC 3986, and a trailing slash names the same +// resource, so audiences are compared in this form. +function canonicalize(audience: string): string { + try { + const url = new URL(audience); + return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${stripTrailingSlashes( + url.pathname, + )}`; + } catch { + return audience; + } +} + +function assertWithinRange( + prop: string, + value: number, + min: number, + max: number, + unit: string, +): void { + if (!Number.isInteger(value) || value < min || value > max) { + throw invalidProp( + prop, + `must be a whole number of ${unit} between ${min} and ${max}, got ${value}`, + ); + } +} + +function parseAbsoluteUrl(prop: string, value: string): URL { + let url: URL; + + try { + url = new URL(value); + } catch { + throw invalidProp(prop, 'must be an absolute URL'); + } + + if (url.protocol !== 'https:' && !LOOPBACK_HOSTNAMES.has(url.hostname)) { + throw invalidProp(prop, 'must use https unless it points at loopback'); + } + + if (url.search !== '' || url.hash !== '') { + throw invalidProp(prop, 'must not contain a query string or fragment'); + } + + return url; +} + +/** + * Run before any OAuth route is served. A value that is merely wrong, rather than + * malformed, otherwise produces a server that looks healthy while a guarantee is 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()); + + // Self-contained, so this TTL is the worst case for how long a revoked connection + // keeps working. + assertWithinRange( + AppSystemProp.OAUTH_ACCESS_TOKEN_TTL_SECONDS, + oauthConfig.getAccessTokenTtlSeconds(), + 60, + 60 * 60, + 'seconds', + ); + + // 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', + ); + + // Also sets how long a revoked row must be retained for reuse detection. + assertWithinRange( + AppSystemProp.OAUTH_REFRESH_TOKEN_TTL_DAYS, + oauthConfig.getRefreshTokenTtlDays(), + 1, + 90, + 'days', + ); + + const mcpResourceUrl = oauthConfig.getMcpResourceUrl(); + if (mcpResourceUrl !== undefined) { + parseAbsoluteUrl(AppSystemProp.MCP_RESOURCE_URL, mcpResourceUrl); + } + + // Distinct audiences are what separate a token the resource server may hold from one + // the API will accept; equal ones would silently void the no-token-passthrough rule. + 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/config/oauth-config.ts b/packages/server/api/src/app/oauth/config/oauth-config.ts new file mode 100644 index 0000000000..faed42e772 --- /dev/null +++ b/packages/server/api/src/app/oauth/config/oauth-config.ts @@ -0,0 +1,39 @@ +import { AppSystemProp, system } from '@openops/server-shared'; +import { stripTrailingSlashes } from '../common/canonical-url'; + +export const oauthConfig = { + isEnabled(): boolean { + return system.getBoolean(AppSystemProp.OAUTH_ENABLED) ?? false; + }, + getIssuerUrl(): string { + return stripTrailingSlashes( + system.getOrThrow(AppSystemProp.OAUTH_ISSUER_URL), + ); + }, + getApiAudience(): string { + return oauthConfig.getIssuerUrl(); + }, + getMcpResourceUrl(): string | undefined { + const value = system.get(AppSystemProp.MCP_RESOURCE_URL); + return value ? stripTrailingSlashes(value) : undefined; + }, + getAccessTokenTtlSeconds(): number { + return system.getNumberOrThrow( + AppSystemProp.OAUTH_ACCESS_TOKEN_TTL_SECONDS, + ); + }, + getRefreshTokenTtlDays(): number { + return system.getNumberOrThrow(AppSystemProp.OAUTH_REFRESH_TOKEN_TTL_DAYS); + }, + getExchangeTokenTtlSeconds(): number { + return system.getNumberOrThrow( + AppSystemProp.OAUTH_EXCHANGE_TOKEN_TTL_SECONDS, + ); + }, + getSigningKeyPemPath(): string | undefined { + return system.get(AppSystemProp.OAUTH_SIGNING_KEY_PEM_PATH); + }, + getResourceServerClientSecret(): string | undefined { + return system.get(AppSystemProp.OAUTH_RS_CLIENT_SECRET); + }, +}; diff --git a/packages/server/api/src/app/oauth/discovery/oauth-metadata.ts b/packages/server/api/src/app/oauth/discovery/oauth-metadata.ts new file mode 100644 index 0000000000..e0181a8c87 --- /dev/null +++ b/packages/server/api/src/app/oauth/discovery/oauth-metadata.ts @@ -0,0 +1,49 @@ +import { stripTrailingSlashes } from '../common/canonical-url'; +import { oauthConfig } from '../config/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 metadata. No OpenID Connect claims: no id tokens are issued, and advertising +// them 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 keeps the issuer's own path component in the metadata path, so an issuer +// served under a sub-path stays discoverable. +export function getWellKnownPathVariants(basePath: string): string[] { + const issuerPath = stripTrailingSlashes( + new URL(oauthConfig.getIssuerUrl()).pathname, + ); + + return issuerPath ? [basePath, `${basePath}${issuerPath}`] : [basePath]; +} diff --git a/packages/server/api/src/app/oauth/discovery/oauth-well-known.controller.ts b/packages/server/api/src/app/oauth/discovery/oauth-well-known.controller.ts new file mode 100644 index 0000000000..b0ecfbe8a2 --- /dev/null +++ b/packages/server/api/src/app/oauth/discovery/oauth-well-known.controller.ts @@ -0,0 +1,53 @@ +import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'; +import { PUBLIC_ROUTE_POLICY } from '@openops/shared'; +import { signingKeyService } from '../tokens/signing-key.service'; +import { + buildAuthorizationServerMetadata, + getWellKnownPathVariants, +} from './oauth-metadata'; + +const METADATA_CACHE_HEADER = 'public, max-age=300'; + +// The MCP authorization spec has clients look under both the RFC 8414 and OpenID Connect +// discovery paths, so the same 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/discovery/resource-registry.ts b/packages/server/api/src/app/oauth/discovery/resource-registry.ts new file mode 100644 index 0000000000..7c985e4f13 --- /dev/null +++ b/packages/server/api/src/app/oauth/discovery/resource-registry.ts @@ -0,0 +1,56 @@ +import { stripTrailingSlashes } from '../common/canonical-url'; +import { oauthConfig } from '../config/oauth-config'; + +export type ResourceId = 'api' | 'mcp'; + +export type RegisteredResource = { + id: ResourceId; + audience: string; + canonicalUri: string; + scopes: string[]; +}; + +/** + * RFC 8707 resource indicators tokens may be issued for. An `mcp` token is only usable + * against the resource server, which exchanges it for an `api` one; that separation is + * enforced by the audience check in `token-exchange.ts`, not here. + */ +export function getRegisteredResources(): RegisteredResource[] { + const apiAudience = oauthConfig.getApiAudience(); + + const resources: RegisteredResource[] = [ + { + id: 'api', + audience: apiAudience, + canonicalUri: apiAudience, + scopes: ['api'], + }, + ]; + + const mcpResourceUrl = oauthConfig.getMcpResourceUrl(); + if (mcpResourceUrl) { + resources.push({ + id: 'mcp', + audience: mcpResourceUrl, + canonicalUri: mcpResourceUrl, + scopes: ['mcp'], + }); + } + + return resources; +} + +export function resolveResource( + resource: string, +): RegisteredResource | undefined { + if (!resource) { + return undefined; + } + + const normalized = stripTrailingSlashes(resource); + return getRegisteredResources().find((r) => r.canonicalUri === normalized); +} + +export function getSupportedScopes(): string[] { + return getRegisteredResources().flatMap((r) => r.scopes); +} diff --git a/packages/server/api/src/app/oauth/oauth-cleanup-job.ts b/packages/server/api/src/app/oauth/oauth-cleanup-job.ts new file mode 100644 index 0000000000..445fcd0aba --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth-cleanup-job.ts @@ -0,0 +1,131 @@ +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 { pendingAuthorizationService } from './authorization/pending-authorization.service'; +import { oauthConfig } from './config/oauth-config'; +import { + OAuthAuthorizationCode, + OAuthClient, + OAuthGrant, + OAuthRefreshToken, +} from './storage/oauth-model'; +import { earlierThan } from './storage/oauth-query'; +import { + OAuthAuthorizationCodeEntity, + OAuthClientEntity, + OAuthGrantEntity, + OAuthRefreshTokenEntity, +} from './storage/oauth.entity'; + +const codeRepo = repoFactory( + OAuthAuthorizationCodeEntity, +); +const refreshTokenRepo = repoFactory( + OAuthRefreshTokenEntity, +); +const clientRepo = repoFactory(OAuthClientEntity); +const grantRepo = repoFactory(OAuthGrantEntity); + +export const OAUTH_CLEANUP_CRON = '0 * * * *'; + +/** + * Registered even when OAuth is disabled: the schedule lives in Redis and outlives the + * process, so an install that turned OAuth off still has this job firing, and a missing + * handler means hourly `No handler for job` retries. + */ +export const registerOAuthCleanupHandler = (): void => { + systemJobHandlers.registerJobHandler( + SystemJobName.OAUTH_CLEANUP, + async (): Promise => { + if (!oauthConfig.isEnabled()) { + return; + } + + try { + await oauthCleanupJobHandler(); + } catch (error) { + logger.error('OAuth cleanup job failed', error); + } + }, + ); +}; + +export const scheduleOAuthCleanupJob = async (): Promise => { + await systemJobsSchedule.upsertJob({ + job: { + name: SystemJobName.OAUTH_CLEANUP, + data: {}, + }, + schedule: { + type: 'repeated', + cron: OAUTH_CLEANUP_CRON, + }, + }); +}; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Registration is open to the network, so unused clients must not accumulate. */ +const UNUSED_CLIENT_RETENTION_DAYS = 30; + +// How long a grant with no usable refresh token stays in the connected-apps list; a +// client that reconnects rather than refreshing would otherwise leave a trail of rows. +const DEAD_GRANT_RETENTION_DAYS = 30; + +export const oauthCleanupJobHandler = async (): Promise => { + const now = Date.now(); + // The query-builder parameters below are bound as Dates, never ISO strings — the + // reason is in `earlierThan`. + const nowDate = new Date(now); + const clientCutoff = new Date(now - UNUSED_CLIENT_RETENTION_DAYS * DAY_MS); + const deadGrantCutoff = new Date(now - DEAD_GRANT_RETENTION_DAYS * DAY_MS); + + const authorizationCodes = await codeRepo().delete({ + expiresAt: earlierThan(nowDate), + }); + const pendingAuthorizations = await pendingAuthorizationService.deleteExpired( + nowDate, + ); + // Expiry is the only anchor, revoked rows included: keeping a rotated token until it + // could no longer be used anyway is what lets reuse detection still recognise a replay + // as a compromise rather than a plain `invalid refresh token`. + const expiredRefreshTokens = await refreshTokenRepo().delete({ + expiresAt: earlierThan(nowDate), + }); + + // `NOT EXISTS` keeps this one statement rather than loading every grant to filter in + // memory. The `none` auth method also spares the 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(); + + // After the refresh-token deletes above, so a grant whose tokens just went counts as + // dead in the same pass. + const deadGrants = await grantRepo() + .createQueryBuilder() + .delete() + .where('COALESCE("lastUsedAt", "created") < :cutoff', { + cutoff: deadGrantCutoff, + }) + .andWhere( + 'NOT EXISTS (SELECT 1 FROM oauth_refresh_token t WHERE t."grantId" = oauth_grant.id AND t."revokedAt" IS NULL)', + ) + .execute(); + + logger.info('OAuth cleanup completed', { + authorizationCodes: authorizationCodes.affected ?? 0, + pendingAuthorizations, + expiredRefreshTokens: expiredRefreshTokens.affected ?? 0, + unusedClients: unusedClients.affected ?? 0, + deadGrants: deadGrants.affected ?? 0, + }); +}; diff --git a/packages/server/api/src/app/oauth/oauth.controller.ts b/packages/server/api/src/app/oauth/oauth.controller.ts new file mode 100644 index 0000000000..23929ba3ff --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth.controller.ts @@ -0,0 +1,429 @@ +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 './authorization/authorize-validation'; +import { pendingAuthorizationService } from './authorization/pending-authorization.service'; +import { + clientsService, + TOKEN_EXCHANGE_GRANT, +} from './clients/clients.service'; +import { grantsService } from './clients/grants.service'; +import { stripTrailingSlashes } from './common/canonical-url'; +import { invalidRequest, unsupportedGrantType } from './common/oauth-errors'; +import { oauthConfig } from './config/oauth-config'; +import { resolveResource } from './discovery/resource-registry'; +import { listAvailableProjects } from './projects/available-projects'; +import { OAuthClient } from './storage/oauth-model'; +import { exchangeToken } from './tokens/token-exchange'; +import { tokensService } from './tokens/tokens.service'; + +const REGISTRATION_RATE_LIMIT: RateLimitOptions = { + max: 10, + timeWindow: '1 minute', +}; + +// Refresh is a routine background operation for connected agents, so this sits well above +// normal use while still bounding brute-force attempts. +const TOKEN_RATE_LIMIT: RateLimitOptions = { + max: 120, + timeWindow: '1 minute', +}; + +// A cross-site form post cannot set a custom header, so requiring this on the decision +// keeps a third party from driving it 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 noStore(reply) + .status(StatusCodes.BAD_REQUEST) + .type('text/html') + .send( + `Authorization error` + + `

Authorization error

${escapeHtml(description)}

` + + `

${escapeHtml(error)}

`, + ); +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +function noStore(reply: FastifyReply): FastifyReply { + return reply.header('Cache-Control', 'no-store').header('Pragma', 'no-cache'); +} + +function getConsentUrl(requestId: string): string { + const frontendUrl = stripTrailingSlashes( + system.getOrThrow(SharedSystemProp.FRONTEND_URL), + ); + + return `${frontendUrl}/settings/connected-apps?request_id=${encodeURIComponent( + requestId, + )}`; +} + +export const oauthController: FastifyPluginAsyncTypebox = async (app) => { + app.post( + '/register', + { + config: { + security: PUBLIC_ROUTE_POLICY, + rateLimit: REGISTRATION_RATE_LIMIT, + }, + schema: { + description: + 'Register an OAuth client dynamically (RFC 7591). Registered clients are public clients and must use PKCE.', + }, + }, + async (request, reply) => { + const registered = await clientsService.registerClient(request.body); + + return noStore(reply).status(StatusCodes.CREATED).send(registered); + }, + ); + + app.get( + '/authorize', + { + config: { + security: PUBLIC_ROUTE_POLICY, + rateLimit: TOKEN_RATE_LIMIT, + }, + schema: { + description: + 'Start an authorization code flow. Validates the request and hands the browser an opaque request id for the consent screen.', + }, + }, + async (request, reply) => { + const query = request.query as AuthorizeQuery; + const clientId = readParam(query, 'client_id'); + const client = clientId ? await clientsService.getClient(clientId) : null; + + const validation = validateAuthorizeRequest(query, client); + + if (validation.kind === 'render_error') { + return renderAuthorizeError( + reply, + validation.error, + validation.description, + ); + } + + if (validation.kind === 'redirect_error') { + // Reached only once the client and its redirect_uri are known good, so this + // cannot be pointed at an unregistered destination. + 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 the request: the user bases their decision on this name, + // so it must not be attacker-supplied. + const client = await clientsService.getClientOrThrow(pending.clientId); + const resource = resolveResource(pending.resource); + + // No project: a connection is not confined to one, so naming where it starts would + // read as a limit that does not exist. + return { + requestId, + clientName: client.clientName, + scope: pending.scope, + resourceId: resource?.id ?? null, + }; + }, + ); + + app.post( + '/requests/:requestId/decision', + { + config: { + security: getUnscopedRoutePolicy([PrincipalType.USER]), + }, + schema: { + description: + 'Approve or deny a pending authorization request and return the URL to send the browser to.', + params: Type.Object({ requestId: Type.String() }), + body: Type.Object({ approve: Type.Boolean() }), + }, + }, + async (request, reply) => { + if (request.headers[CONSENT_HEADER] === undefined) { + throw invalidRequest(`the ${CONSENT_HEADER} header is required`); + } + + const { requestId } = request.params as { requestId: string }; + const { approve } = request.body as { approve: boolean }; + + const pending = await pendingAuthorizationService.consume(requestId); + + if (!approve) { + return noStore(reply).send({ + redirectTo: buildRedirectUrl(pending.redirectUri, { + error: 'access_denied', + error_description: 'The user denied the request.', + state: pending.state ?? undefined, + }), + }); + } + + const code = await tokensService.issueAuthorizationCode( + pending, + request.principal.id, + ); + + logger.info('OAuth authorization approved', { + clientId: pending.clientId, + userId: request.principal.id, + resource: pending.resource, + }); + + return noStore(reply).send({ + redirectTo: buildRedirectUrl(pending.redirectUri, { + code, + state: pending.state ?? undefined, + }), + }); + }, + ); + + app.post( + '/token', + { + config: { + security: PUBLIC_ROUTE_POLICY, + rateLimit: TOKEN_RATE_LIMIT, + }, + schema: { + description: + 'Exchange an authorization code, refresh token, or subject token for an access token.', + }, + }, + async (request, reply) => { + const body = (request.body ?? {}) as OAuthRequestBody; + + switch (optionalParam(body, 'grant_type')) { + case 'authorization_code': + return noStore(reply).send(await handleAuthorizationCodeGrant(body)); + case 'refresh_token': + return noStore(reply).send(await handleRefreshTokenGrant(body)); + case TOKEN_EXCHANGE_GRANT: + return noStore(reply).send( + await exchangeToken({ + authorizationHeader: request.headers.authorization, + subjectToken: requireParam(body, 'subject_token'), + subjectTokenType: optionalParam(body, 'subject_token_type'), + requestedProjectId: optionalParam(body, 'project_id'), + }), + ); + default: + throw unsupportedGrantType( + `unsupported grant_type: ${ + optionalParam(body, 'grant_type') ?? 'missing' + }`, + ); + } + }, + ); + + app.post( + '/revoke', + { + config: { + security: PUBLIC_ROUTE_POLICY, + rateLimit: TOKEN_RATE_LIMIT, + }, + schema: { + description: + 'Revoke a refresh token and the connection it belongs to (RFC 7009).', + }, + }, + async (request, reply) => { + const body = (request.body ?? {}) as OAuthRequestBody; + const token = optionalParam(body, 'token'); + + if (token) { + await tokensService.revokeByRefreshToken(token); + } + + // RFC 7009 §2.2: an unknown token is not an error. + return noStore(reply).status(StatusCodes.OK).send({}); + }, + ); + + app.get( + '/projects', + { + config: { + // SERVICE as well as USER: the one route a connection itself calls, to find out + // where it may switch to. It returns no project data, only names. + security: getUnscopedRoutePolicy([ + PrincipalType.USER, + PrincipalType.SERVICE, + ]), + }, + schema: { + description: + 'The projects the caller may act in, and which one they are acting in now.', + }, + }, + async (request) => { + const projects = await listAvailableProjects(request.principal.id); + + return { + data: projects, + currentProjectId: request.principal.projectId, + }; + }, + ); + + app.get( + '/grants', + { + config: { + security: getUnscopedRoutePolicy([PrincipalType.USER]), + }, + schema: { + description: 'List the connected applications for the current user.', + }, + }, + async (request) => { + const grants = await grantsService.listForUser(request.principal.id); + const clients = await Promise.all( + grants.map((grant) => clientsService.getClient(grant.clientId)), + ); + + return { + data: grants.map((grant, index) => ({ + id: grant.id, + clientName: clients[index]?.clientName ?? 'Unknown application', + resourceId: grant.resourceId, + created: grant.created, + lastUsedAt: grant.lastUsedAt, + })), + }; + }, + ); + + app.delete( + '/grants/:grantId', + { + config: { + security: getUnscopedRoutePolicy([PrincipalType.USER]), + }, + schema: { + description: + 'Revoke a connected application, invalidating its refresh tokens.', + params: Type.Object({ grantId: Type.String() }), + }, + }, + async (request, reply) => { + const { grantId } = request.params as { grantId: string }; + + await grantsService.revokeForUser(grantId, request.principal.id); + + return reply.status(StatusCodes.OK).send({}); + }, + ); +}; + +async function handleAuthorizationCodeGrant( + body: OAuthRequestBody, +): Promise { + const clientId = requireParam(body, 'client_id'); + const client = await clientsService.getClientOrThrow(clientId); + clientsService.assertGrantTypeAllowed(client, 'authorization_code'); + + return tokensService.redeemAuthorizationCode({ + code: requireParam(body, 'code'), + clientId, + redirectUri: requireParam(body, 'redirect_uri'), + codeVerifier: requireParam(body, 'code_verifier'), + resource: requireParam(body, 'resource'), + }); +} + +async function handleRefreshTokenGrant( + body: OAuthRequestBody, +): Promise { + const clientId = requireParam(body, 'client_id'); + const client = await clientsService.getClientOrThrow(clientId); + clientsService.assertGrantTypeAllowed(client, 'refresh_token'); + + return tokensService.rotateRefreshToken({ + refreshToken: requireParam(body, 'refresh_token'), + clientId, + requestedProjectId: optionalParam(body, 'project_id'), + }); +} diff --git a/packages/server/api/src/app/oauth/oauth.module.ts b/packages/server/api/src/app/oauth/oauth.module.ts new file mode 100644 index 0000000000..b0952b9d2e --- /dev/null +++ b/packages/server/api/src/app/oauth/oauth.module.ts @@ -0,0 +1,43 @@ +import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'; +import { logger } from '@openops/server-shared'; +import { clientsService } from './clients/clients.service'; +import { OAuthError } from './common/oauth-errors'; +import { validateOAuthConfiguration } from './config/oauth-config-validation'; +import { oauthWellKnownController } from './discovery/oauth-well-known.controller'; +import { scheduleOAuthCleanupJob } from './oauth-cleanup-job'; +import { oauthController } from './oauth.controller'; +import { signingKeyService } from './tokens/signing-key.service'; + +export const oauthModule: FastifyPluginAsyncTypebox = async (app) => { + validateOAuthConfiguration(); + + await signingKeyService.ensureSigningKey(); + await clientsService.ensureResourceServerClient(); + await scheduleOAuthCleanupJob(); + + await app.register( + async (instance) => { + instance.setErrorHandler((error, _request, reply) => { + if (error instanceof OAuthError) { + logger.debug('OAuth request rejected', { + error: error.errorCode, + description: error.description, + }); + + return reply + .status(error.statusCode) + .header('Cache-Control', 'no-store') + .send(error.toBody()); + } + + throw error; + }); + + await instance.register(oauthController, { prefix: '/v1/oauth' }); + await instance.register(oauthWellKnownController); + }, + { prefix: '/' }, + ); + + logger.info('OAuth authorization server enabled'); +}; diff --git a/packages/server/api/src/app/oauth/projects/available-projects.ts b/packages/server/api/src/app/oauth/projects/available-projects.ts new file mode 100644 index 0000000000..79a2c660e3 --- /dev/null +++ b/packages/server/api/src/app/oauth/projects/available-projects.ts @@ -0,0 +1,38 @@ +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; +}; + +// Names are resolved here rather than by the membership service, which answers questions +// about authority, not 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, + ); + + // One query rather than one per membership: agents poll this to decide where to switch. + const projects = await projectService.getManyByIds( + memberships.map((membership) => membership.projectId), + ); + const displayNames = new Map( + projects.map((project) => [project.id, project.displayName]), + ); + + return memberships.map((membership) => ({ + projectId: membership.projectId, + projectName: displayNames.get(membership.projectId) ?? membership.projectId, + })); +} diff --git a/packages/server/api/src/app/oauth/projects/project-membership-factory.ts b/packages/server/api/src/app/oauth/projects/project-membership-factory.ts new file mode 100644 index 0000000000..10f8a35346 --- /dev/null +++ b/packages/server/api/src/app/oauth/projects/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/projects/project-membership.ts b/packages/server/api/src/app/oauth/projects/project-membership.ts new file mode 100644 index 0000000000..009a608dac --- /dev/null +++ b/packages/server/api/src/app/oauth/projects/project-membership.ts @@ -0,0 +1,78 @@ +import { isNil, User } from '@openops/shared'; +import { projectService } from '../../project/project-service'; + +// `projectRole` is a plain string because the role model is an enterprise concern; this +// edition reports the same value the session login path does. +export type OAuthProjectMembership = { + projectId: string; + organizationId: string; + projectRole: string; +}; + +/** + * Behind a factory (`project-membership-factory.ts`) so an edition with real + * multi-project membership can answer these without the OAuth code changing. + */ +export type OAuthProjectMembershipService = { + getDefaultForUser(user: User): Promise; + /** Re-checked on every OAuth request, so losing access takes effect before expiry. */ + getForUser( + user: User, + projectId: string, + ): Promise; + listForUser(user: User): Promise; +}; + +// One project per organization and no role model in this edition. +const PROJECT_ROLE = 'ADMIN'; + +export const oauthProjectMembershipService: OAuthProjectMembershipService = { + async getDefaultForUser(user: User): Promise { + const project = await projectService.getOneForUser(user); + + if (isNil(project)) { + return null; + } + + return { + projectId: project.id, + organizationId: project.organizationId, + projectRole: PROJECT_ROLE, + }; + }, + + async getForUser( + user: User, + projectId: string, + ): Promise { + const project = await projectService.getOne(projectId); + + if (isNil(project) || project.organizationId !== user.organizationId) { + return null; + } + + return { + projectId: project.id, + organizationId: project.organizationId, + projectRole: PROJECT_ROLE, + }; + }, + + async listForUser(user: User): Promise { + // Same rule as `getForUser`: listing less than that allows would tell a client it + // may act in one place while the token endpoint switched it to another. + 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/projects/service-principal.ts b/packages/server/api/src/app/oauth/projects/service-principal.ts new file mode 100644 index 0000000000..f987cd6596 --- /dev/null +++ b/packages/server/api/src/app/oauth/projects/service-principal.ts @@ -0,0 +1,63 @@ +import { isNil, Principal, PrincipalType, UserStatus } from '@openops/shared'; +import { userService } from '../../user/user-service'; +import { grantsService } from '../clients/grants.service'; +import { invalidGrant } from '../common/oauth-errors'; +import { OAuthAccessTokenClaims } from '../storage/oauth-model'; +import { getOAuthProjectMembershipService } from './project-membership-factory'; + +/** + * Turns a verified OAuth access token into a request principal. Audience is already + * checked by the caller, and the project comes from the token's own claim. + * + * Everything that can change after issuance — revocation, deactivation, withdrawn project + * access — is re-checked here, since access tokens are self-contained. + */ +export async function buildOAuthServicePrincipal( + claims: OAuthAccessTokenClaims, +): Promise { + if (!claims.grant_id) { + throw invalidGrant('token is not bound to an authorization'); + } + + // Required: 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'); + } + + // Also called at token exchange, so a connection used directly against the API still + // shows a last-used time. + await grantsService.touch(grant.id); + + return { + id: user.id, + externalId: user.externalId, + type: PrincipalType.SERVICE, + projectId: membership.projectId, + projectRole: membership.projectRole, + organization: { + id: membership.organizationId, + role: user.organizationRole, + }, + }; +} diff --git a/packages/server/api/src/app/oauth/storage/oauth-model.ts b/packages/server/api/src/app/oauth/storage/oauth-model.ts new file mode 100644 index 0000000000..57f997d14b --- /dev/null +++ b/packages/server/api/src/app/oauth/storage/oauth-model.ts @@ -0,0 +1,104 @@ +import { BaseModel } from '@openops/shared'; + +export type OAuthSigningKeyStatus = 'active' | 'retiring' | 'retired'; + +export type OAuthSigningKey = BaseModel & { + /** AES-encrypted PKCS#8 private key, serialized `EncryptedObject` JSON. */ + privateKeyEncrypted: string; + publicKeyPem: string; + status: OAuthSigningKeyStatus; +}; + +export type OAuthTokenEndpointAuthMethod = 'none' | 'client_secret_basic'; + +// No `scope`: what a token gets is decided by the resource it names, checked at +// `/authorize`. Storing a registered scope would be a second, unconsulted answer. +export type OAuthClient = BaseModel & { + clientName: string; + redirectUris: string[]; + grantTypes: string[]; + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod; + clientSecretHash: string | null; +}; + +/** + * A validated `/authorize` request awaiting the user's decision. Held server-side so + * consent cannot be forged through crafted URL parameters. + */ +export type OAuthPendingAuthorization = BaseModel & { + clientId: string; + redirectUri: string; + codeChallenge: string; + resource: string; + scope: string; + state: string | null; + expiresAt: string; + consumedAt: string | null; +}; + +export type OAuthAuthorizationCode = BaseModel & { + codeHash: string; + clientId: string; + userId: string; + redirectUri: string; + codeChallenge: string; + resource: string; + scope: string; + expiresAt: string; + consumedAt: string | null; +}; + +// No `userId`: the grant is where the acting user is recorded. +export type OAuthRefreshToken = BaseModel & { + tokenHash: string; + grantId: string; + /** Shared by every token rotated from the same original issuance. */ + familyId: string; + clientId: string; + resource: string; + scope: string; + /** Where this chain is acting; carried forward on rotation unless the client moves it. */ + projectId: string; + expiresAt: string; + revokedAt: string | null; +}; + +export type OAuthGrantStatus = 'active' | 'revoked'; + +/** + * One authorized connection. A user may hold several for the same client, each from a + * separate authorization, and revoke them independently. + * + * No `projectId`: a connection can switch project, so it lives on the refresh token that + * carries the chain forward. No `scope`: it would restate `resourceId`. + */ +export type OAuthGrant = BaseModel & { + clientId: string; + userId: string; + resourceId: string; + status: OAuthGrantStatus; + lastUsedAt: string | null; + revokedAt: string | null; +}; + +export type OAuthAccessTokenClaims = { + iss: string; + sub: string; + aud: string; + exp: number; + iat: number; + jti: string; + client_id: string; + scope: string; + grant_id: string; + /** The only project this token may act on. Fixed at mint time. */ + 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/storage/oauth-query.ts b/packages/server/api/src/app/oauth/storage/oauth-query.ts new file mode 100644 index 0000000000..c8aa3aba7c --- /dev/null +++ b/packages/server/api/src/app/oauth/storage/oauth-query.ts @@ -0,0 +1,10 @@ +import { FindOperator, LessThan } from 'typeorm'; + +/** + * Timestamp columns are typed as `string` but must be compared as a `Date`: drivers + * serialise dates in their own textual format, so an ISO-string predicate is a textual + * comparison that can match every row. The cast is confined here. + */ +export function earlierThan(instant: Date): FindOperator { + return LessThan(instant) as unknown as FindOperator; +} diff --git a/packages/server/api/src/app/oauth/storage/oauth.entity.ts b/packages/server/api/src/app/oauth/storage/oauth.entity.ts new file mode 100644 index 0000000000..d1c6172ac9 --- /dev/null +++ b/packages/server/api/src/app/oauth/storage/oauth.entity.ts @@ -0,0 +1,153 @@ +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: what makes concurrently booting + // replicas converge on one active key. + indices: [ + { + name: 'idx_oauth_signing_key_single_active', + columns: ['status'], + unique: true, + where: '"status" = \'active\'', + }, + ], +}); + +export const OAuthClientEntity = new EntitySchema({ + name: 'oauth_client', + columns: { + ...BaseColumnSchemaPart, + clientName: { type: String, length: 128 }, + redirectUris: { type: JSONB_COLUMN_TYPE }, + grantTypes: { type: JSONB_COLUMN_TYPE }, + tokenEndpointAuthMethod: { type: String, length: 32 }, + clientSecretHash: { + type: String, + length: SHA256_HEX_LENGTH, + nullable: true, + }, + }, + indices: [], +}); + +export const OAuthPendingAuthorizationEntity = + new EntitySchema({ + name: 'oauth_pending_authorization', + columns: { + ...BaseColumnSchemaPart, + clientId: { ...OpenOpsIdSchema }, + redirectUri: { type: String, length: URI_LENGTH }, + codeChallenge: { type: String, length: CODE_CHALLENGE_LENGTH }, + resource: { type: String, length: URI_LENGTH }, + scope: { type: String, length: 128 }, + state: { type: String, nullable: true }, + expiresAt: { type: TIMESTAMP_COLUMN_TYPE }, + consumedAt: { type: TIMESTAMP_COLUMN_TYPE, nullable: true }, + }, + indices: [ + { + name: 'idx_oauth_pending_authorization_expires_at', + columns: ['expiresAt'], + }, + ], + }); + +export const OAuthAuthorizationCodeEntity = + new EntitySchema({ + name: 'oauth_authorization_code', + columns: { + ...BaseColumnSchemaPart, + codeHash: { type: String, length: SHA256_HEX_LENGTH }, + clientId: { ...OpenOpsIdSchema }, + userId: { ...OpenOpsIdSchema }, + redirectUri: { type: String, length: URI_LENGTH }, + codeChallenge: { type: String, length: CODE_CHALLENGE_LENGTH }, + resource: { type: String, length: URI_LENGTH }, + scope: { type: String, length: 128 }, + expiresAt: { type: TIMESTAMP_COLUMN_TYPE }, + consumedAt: { type: TIMESTAMP_COLUMN_TYPE, nullable: true }, + }, + indices: [ + { + name: 'idx_oauth_authorization_code_code_hash', + columns: ['codeHash'], + unique: true, + }, + { + name: 'idx_oauth_authorization_code_expires_at', + columns: ['expiresAt'], + }, + ], + }); + +export const OAuthRefreshTokenEntity = new EntitySchema({ + name: 'oauth_refresh_token', + columns: { + ...BaseColumnSchemaPart, + tokenHash: { type: String, length: SHA256_HEX_LENGTH }, + grantId: { ...OpenOpsIdSchema }, + familyId: { ...OpenOpsIdSchema }, + clientId: { ...OpenOpsIdSchema }, + resource: { type: String, length: URI_LENGTH }, + scope: { type: String, length: 128 }, + projectId: { ...OpenOpsIdSchema }, + expiresAt: { type: TIMESTAMP_COLUMN_TYPE }, + revokedAt: { type: TIMESTAMP_COLUMN_TYPE, nullable: true }, + }, + indices: [ + { + name: 'idx_oauth_refresh_token_token_hash', + columns: ['tokenHash'], + unique: true, + }, + { name: 'idx_oauth_refresh_token_grant_id', columns: ['grantId'] }, + { name: 'idx_oauth_refresh_token_family_id', columns: ['familyId'] }, + { name: 'idx_oauth_refresh_token_expires_at', columns: ['expiresAt'] }, + ], +}); + +export const OAuthGrantEntity = new EntitySchema({ + name: 'oauth_grant', + columns: { + ...BaseColumnSchemaPart, + clientId: { ...OpenOpsIdSchema }, + userId: { ...OpenOpsIdSchema }, + resourceId: { type: String, length: 32 }, + status: { type: String, length: 16 }, + lastUsedAt: { type: TIMESTAMP_COLUMN_TYPE, nullable: true }, + revokedAt: { type: TIMESTAMP_COLUMN_TYPE, nullable: true }, + }, + // Not unique on (clientId, userId): a user may connect the same agent more than once. + indices: [ + { + name: 'idx_oauth_grant_client_id_user_id', + columns: ['clientId', 'userId'], + }, + { name: 'idx_oauth_grant_user_id', columns: ['userId'] }, + ], +}); diff --git a/packages/server/api/src/app/oauth/tokens/signing-key.service.ts b/packages/server/api/src/app/oauth/tokens/signing-key.service.ts new file mode 100644 index 0000000000..0c29b021e9 --- /dev/null +++ b/packages/server/api/src/app/oauth/tokens/signing-key.service.ts @@ -0,0 +1,270 @@ +import { AppSystemProp, encryptUtils, logger } from '@openops/server-shared'; +import { + ApplicationError, + EncryptedObject, + ErrorCode, + 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 { invalidGrant, serverError } from '../common/oauth-errors'; +import { oauthConfig } from '../config/oauth-config'; +import { OAuthSigningKey } from '../storage/oauth-model'; +import { OAuthSigningKeyEntity } from '../storage/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 }; + /** Active plus retiring: every key a token may legitimately have been signed with. */ + 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(), + }; +} + +// Nothing reads an operator-supplied key until the first sign or verify, so without this +// a bad path or a public key in place of a private one boots a healthy-looking server that +// fails on its first token request. +function assertOperatorKeyIsUsable(pemPath: string): void { + const invalid = (reason: string): ApplicationError => + new ApplicationError( + { + code: ErrorCode.SYSTEM_PROP_INVALID, + params: { prop: AppSystemProp.OAUTH_SIGNING_KEY_PEM_PATH }, + }, + `OPS_${AppSystemProp.OAUTH_SIGNING_KEY_PEM_PATH} ${reason}`, + ); + + let key: crypto.KeyObject; + + try { + key = crypto.createPrivateKey(fs.readFileSync(pemPath, 'utf-8')); + } catch (error) { + throw invalid( + `must point at a readable PEM private key: ${(error as Error).message}`, + ); + } + + // Tokens are signed with RS256, so any other key type fails only at sign time. + if (key.asymmetricKeyType !== 'rsa') { + throw invalid( + `must be an RSA private key to sign ${ALGORITHM} tokens, got ${key.asymmetricKeyType}`, + ); + } +} + +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 already-issued tokens verifiable instead of telling 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 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 reuses the winner's key. + */ + async ensureSigningKey(): Promise { + const pemPath = oauthConfig.getSigningKeyPemPath(); + + if (pemPath) { + assertOperatorKeyIsUsable(pemPath); + 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 signed rather than looked up per request, so a token 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, + }, + ); + }, + + // Audience is required here rather than checked 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/tokens/token-exchange.ts b/packages/server/api/src/app/oauth/tokens/token-exchange.ts new file mode 100644 index 0000000000..fad97d3bf1 --- /dev/null +++ b/packages/server/api/src/app/oauth/tokens/token-exchange.ts @@ -0,0 +1,121 @@ +import { isNil, UserStatus } from '@openops/shared'; +import { userService } from '../../user/user-service'; +import { + clientsService, + TOKEN_EXCHANGE_GRANT, +} from '../clients/clients.service'; +import { grantsService } from '../clients/grants.service'; +import { + invalidGrant, + invalidRequest, + invalidTarget, +} from '../common/oauth-errors'; +import { oauthConfig } from '../config/oauth-config'; +import { getOAuthProjectMembershipService } from '../projects/project-membership-factory'; +import { signingKeyService } from './signing-key.service'; +import { tokensService } from './tokens.service'; + +const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'; +const EXCHANGED_SCOPE = 'api'; + +export type ExchangeTokenParams = { + authorizationHeader: string | undefined; + subjectToken: string; + subjectTokenType?: string; + /** Act in this project instead of the subject token's. Must be one the user has. */ + requestedProjectId?: string; +}; + +export type ExchangeTokenResponse = { + access_token: string; + issued_token_type: string; + token_type: 'Bearer'; + expires_in: number; + scope: string; +}; + +/** + * RFC 8693 token exchange for the hosted MCP resource server. The client's token is bound + * to the MCP audience and must never reach the API (the MCP authorization spec's + * no-token-passthrough rule), so it is swapped here for a short-lived API-audience one. + */ +export async function exchangeToken( + params: ExchangeTokenParams, +): Promise { + // First, so an unauthenticated caller cannot 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 audience 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); + const user = await userService.get({ id: grant.userId }); + + if (isNil(user) || user.status !== UserStatus.ACTIVE) { + throw invalidGrant('the user for this authorization is no longer active'); + } + + const subjectProjectId = claims['project_id']; + + if (typeof subjectProjectId !== 'string') { + throw invalidGrant('token is not bound to a project'); + } + + // Defaults to the subject token's project; a resource server may name another, which is + // how an agent switches project without re-authorizing. Bounded by the user's own + // membership, re-read here, so a switch can never reach further than the browser could. + const targetProjectId = params.requestedProjectId ?? subjectProjectId; + + const membership = await getOAuthProjectMembershipService().getForUser( + user, + targetProjectId, + ); + + if (isNil(membership)) { + throw invalidTarget('the requested project is not accessible'); + } + + const { accessToken, expiresIn } = await tokensService.mintExchangedApiToken({ + grant: { id: grant.id, userId: grant.userId, clientId: grant.clientId }, + scope: EXCHANGED_SCOPE, + projectId: membership.projectId, + }); + + await grantsService.touch(grant.id); + + return { + access_token: accessToken, + issued_token_type: ACCESS_TOKEN_TYPE, + token_type: 'Bearer', + expires_in: expiresIn, + scope: EXCHANGED_SCOPE, + }; +} diff --git a/packages/server/api/src/app/oauth/tokens/tokens.service.ts b/packages/server/api/src/app/oauth/tokens/tokens.service.ts new file mode 100644 index 0000000000..d313c48048 --- /dev/null +++ b/packages/server/api/src/app/oauth/tokens/tokens.service.ts @@ -0,0 +1,409 @@ +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 { verifyPkce } from '../authorization/pkce'; +import { grantsService } from '../clients/grants.service'; +import { generateOpaqueToken, sha256Hex } from '../common/oauth-crypto'; +import { invalidGrant, invalidTarget } from '../common/oauth-errors'; +import { oauthConfig } from '../config/oauth-config'; +import { resolveResource } from '../discovery/resource-registry'; +import { getOAuthProjectMembershipService } from '../projects/project-membership-factory'; +import { + OAuthAuthorizationCode, + OAuthGrant, + OAuthPendingAuthorization, + OAuthRefreshToken, + OAuthTokenResponse, +} from '../storage/oauth-model'; +import { + OAuthAuthorizationCodeEntity, + OAuthRefreshTokenEntity, +} from '../storage/oauth.entity'; +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 +// before their tokens 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; +} + +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; +} + +// Access can be withdrawn after a connection is made, so refreshing must not hand out a +// token for a project the user can no longer reach. +async function authorizeProjectOrThrow( + user: User, + projectId: string, + wasRequested = false, +): Promise { + const membership = await getOAuthProjectMembershipService().getForUser( + user, + projectId, + ); + + if (isNil(membership)) { + // A client naming a project it may not have can correct the request + // (`invalid_target`, RFC 8707); a connection whose own project became unreachable + // is stale and can only be re-authorized (`invalid_grant`). + throw wasRequested + ? invalidTarget('the requested project is not accessible') + : invalidGrant('the project for this authorization is not accessible'); + } + + return membership.projectId; +} + +async function mintAccessToken(params: { + grant: Pick; + audience: string; + scope: string; + projectId: string; + ttlSeconds: number; +}): Promise { + return signingKeyService.signAccessToken( + { + sub: params.grant.userId, + aud: params.audience, + client_id: params.grant.clientId, + scope: params.scope, + grant_id: params.grant.id, + project_id: params.projectId, + }, + params.ttlSeconds, + ); +} + +async function issueRefreshToken(params: { + grantId: string; + familyId: string; + clientId: string; + resource: string; + scope: string; + projectId: string; +}): Promise { + const token = generateOpaqueToken(); + const now = new Date(); + const expiresAt = new Date( + now.getTime() + oauthConfig.getRefreshTokenTtlDays() * 24 * 60 * 60 * 1000, + ); + + await refreshTokenRepo().insert({ + id: openOpsId(), + created: now.toISOString(), + updated: now.toISOString(), + tokenHash: sha256Hex(token), + grantId: params.grantId, + familyId: params.familyId, + clientId: params.clientId, + resource: params.resource, + scope: params.scope, + projectId: params.projectId, + expiresAt: expiresAt.toISOString(), + revokedAt: null, + }); + + return token; +} + +export type RedeemAuthorizationCodeParams = { + code: string; + clientId: string; + redirectUri: string; + codeVerifier: string; + resource: string; +}; + +export type RotateRefreshTokenParams = { + refreshToken: string; + clientId: string; + /** Switch to another project the user belongs to; omitted keeps the current one. */ + requestedProjectId?: string; +}; + +export const tokensService = { + /** + * Issues a single-use code, stored only as a hash. Every parameter the token endpoint + * re-checks later 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); + + // Claimed before anything else is validated: 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) { + throw invalidGrant(UNUSABLE_CODE); + } + + if (resource.canonicalUri !== codeRecord.resource) { + throw invalidGrant(UNUSABLE_CODE); + } + + if (!verifyPkce(params.codeVerifier, codeRecord.codeChallenge)) { + throw invalidGrant(UNUSABLE_CODE); + } + + const user = await loadActiveUserOrThrow(codeRecord.userId); + // Recorded on the refresh token rather than the grant: it is a property of the + // credential chain and changes when the client switches project. + const projectId = await resolveDefaultProjectId(user); + const grant = await grantsService.create({ + clientId: codeRecord.clientId, + userId: codeRecord.userId, + resourceId: resource.id, + }); + + const accessToken = await mintAccessToken({ + grant, + audience: resource.audience, + scope: codeRecord.scope, + projectId, + ttlSeconds: oauthConfig.getAccessTokenTtlSeconds(), + }); + + const refreshToken = await issueRefreshToken({ + grantId: grant.id, + familyId: openOpsId(), + clientId: grant.clientId, + resource: resource.canonicalUri, + scope: codeRecord.scope, + projectId, + }); + + return { + access_token: accessToken, + token_type: 'Bearer', + expires_in: oauthConfig.getAccessTokenTtlSeconds(), + scope: codeRecord.scope, + refresh_token: refreshToken, + }; + }, + + /** + * Rotates a refresh token (OAuth 2.1 §4.3.1). An already-rotated token means a replay + * or a stolen token racing the real client — indistinguishable from here — so the whole + * family is revoked and the connection must 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'); + } + + // Judged before the token is consumed: revoking on the way in would let one rejected + // request destroy a working credential, and the client's retry would look like a + // replay. + if (existingToken.clientId !== params.clientId) { + throw invalidGrant('invalid refresh token'); + } + + if (isExpired(existingToken.expiresAt, Date.now())) { + throw invalidGrant('refresh token expired'); + } + + const grant = await grantsService.getActiveGrantOrThrow( + existingToken.grantId, + ); + const user = await loadActiveUserOrThrow(grant.userId); + // A refresh is where a connection changes project. Defaulting to the presented + // token's own project keeps a plain renewal equivalent to what it replaces. + const projectId = await authorizeProjectOrThrow( + user, + params.requestedProjectId ?? existingToken.projectId, + params.requestedProjectId !== undefined, + ); + + const resource = resolveResource(existingToken.resource); + + if (!resource) { + throw invalidGrant( + 'the resource for this authorization no longer exists', + ); + } + + // Only now consumed. The conditional update 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) { + // Revoking a 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, + familyId: existingToken.familyId, + clientId: existingToken.clientId, + resource: existingToken.resource, + scope: existingToken.scope, + projectId, + }); + + return { + access_token: accessToken, + token_type: 'Bearer', + expires_in: oauthConfig.getAccessTokenTtlSeconds(), + scope: existingToken.scope, + refresh_token: refreshToken, + }; + }, + + async revokeFamily(familyId: string): Promise { + await refreshTokenRepo().update( + { familyId, revokedAt: IsNull() }, + { revokedAt: new Date().toISOString() }, + ); + }, + + /** RFC 7009: revoking any refresh token revokes the whole connection. */ + async revokeByRefreshToken(refreshToken: string): Promise { + const record = await refreshTokenRepo().findOneBy({ + tokenHash: sha256Hex(refreshToken), + }); + + if (!record) { + return; + } + + await grantsService.revoke(record.grantId); + }, + + /** + * The API-audience token handed to a resource server. `projectId` is explicit so the + * claim, not any stored state, decides what the token 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/src/app/project/project-service.ts b/packages/server/api/src/app/project/project-service.ts index 83404c6802..b725a288d7 100644 --- a/packages/server/api/src/app/project/project-service.ts +++ b/packages/server/api/src/app/project/project-service.ts @@ -17,7 +17,7 @@ import { User, UserId, } from '@openops/shared'; -import { IsNull } from 'typeorm'; +import { In, IsNull } from 'typeorm'; import { repoFactory } from '../core/db/repo-factory'; import { openopsTables } from '../openops-tables'; import { ProjectEntity } from './project-entity'; @@ -96,6 +96,16 @@ export const projectService = { return projects.map((project) => project.id); }, + async getManyByIds(projectIds: ProjectId[]): Promise { + if (projectIds.length === 0) { + return []; + } + + return projectRepo().find({ + where: { id: In(projectIds), deleted: IsNull() }, + }); + }, + async getOneOrThrow(projectId: ProjectId): Promise { const project = await this.getOne(projectId); diff --git a/packages/server/api/test/integration/ce/authentication/signup.test.ts b/packages/server/api/test/integration/ce/authentication/signup.test.ts index 3355c1a54c..104bc7be6e 100644 --- a/packages/server/api/test/integration/ce/authentication/signup.test.ts +++ b/packages/server/api/test/integration/ce/authentication/signup.test.ts @@ -1,5 +1,3 @@ -import { accessTokenManager } from '../../../../src/app/authentication/context/access-token-manager'; - const authUserMock = jest.fn().mockResolvedValue({ token: 'token', refresh_token: 'refresh_token', @@ -38,6 +36,7 @@ jest.mock('../../../../src/app/openops-tables/index', () => ({ import { PrincipalType, UserStatus } from '@openops/shared'; import { FastifyInstance } from 'fastify'; import { StatusCodes } from 'http-status-codes'; +import { accessTokenManager } from '../../../../src/app/authentication/context/access-token-manager'; import { databaseConnection } from '../../../../src/app/database/database-connection'; import { setupServer } from '../../../../src/app/server'; import { generateMockToken } from '../../../helpers/auth'; diff --git a/packages/server/api/test/integration/ce/oauth/oauth-consumption.test.ts b/packages/server/api/test/integration/ce/oauth/oauth-consumption.test.ts new file mode 100644 index 0000000000..ccde633054 --- /dev/null +++ b/packages/server/api/test/integration/ce/oauth/oauth-consumption.test.ts @@ -0,0 +1,414 @@ +import { encryptUtils } from '@openops/server-shared'; +import { UserStatus } from '@openops/shared'; +import { IsNull } from 'typeorm'; +import { databaseConnection } from '../../../../src/app/database/database-connection'; +import { pendingAuthorizationService } from '../../../../src/app/oauth/authorization/pending-authorization.service'; +import { grantsService } from '../../../../src/app/oauth/clients/grants.service'; +import { oauthConfig } from '../../../../src/app/oauth/config/oauth-config'; +import { oauthCleanupJobHandler } from '../../../../src/app/oauth/oauth-cleanup-job'; +import { signingKeyService } from '../../../../src/app/oauth/tokens/signing-key.service'; +import { tokensService } from '../../../../src/app/oauth/tokens/tokens.service'; +import { + createMockOrganization, + createMockProject, + createMockUser, +} from '../../../helpers/mocks'; + +/** + * The guarantees in-memory repositories cannot observe: that single-use consumption really + * is a conditional UPDATE the database serialises, and that the cleanup job's SQL deletes + * the rows it should and no others. Runs on SQLite rather than the production driver, but + * against 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 }); + 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(); + + expect(await repo('oauth_grant').count()).toBe(1); + }); + + it('keeps a client a connection still references, and deletes one nothing does', async () => { + await issueConnection(); + await updateAll('oauth_client', { created: LONG_AGO }); + + await oauthCleanupJobHandler(); + + const remaining = await repo('oauth_client').find(); + expect(remaining.map((row) => row.id)).toEqual([CLIENT_ID]); + }); +}); + +describe('signing keys', () => { + it('keeps a token verifiable after its key starts retiring', async () => { + const token = await signingKeyService.signAccessToken( + { + sub: userId, + aud: ISSUER, + client_id: CLIENT_ID, + scope: 'api', + grant_id: 'grant000000000000001', + project_id: projectId, + }, + 900, + ); + + await repo('oauth_signing_key').update( + { status: 'active' }, + { status: 'retiring' }, + ); + signingKeyService.clearKeyCacheForTests(); + await signingKeyService.ensureSigningKey(); + + await expect( + signingKeyService.verifyAccessToken(token, ISSUER), + ).resolves.toMatchObject({ sub: userId }); + expect((await signingKeyService.getJwks()).keys).toHaveLength(2); + }); +}); diff --git a/packages/server/api/test/integration/ce/oauth/oauth-routes.test.ts b/packages/server/api/test/integration/ce/oauth/oauth-routes.test.ts new file mode 100644 index 0000000000..c91448e42d --- /dev/null +++ b/packages/server/api/test/integration/ce/oauth/oauth-routes.test.ts @@ -0,0 +1,367 @@ +/* + * The HTTP contract, which only appears once the real app is running: which principal each + * route admits, the anti-CSRF header, cache headers, the RFC 6749 error envelope and the + * open-redirect boundary. `securityHandlerChain` is a global `preHandler` registered by + * `setupApp`, so a hand-built Fastify instance would enforce none of it. + * + * The boot guard refuses SQLite because the migration is Postgres-only, but this + * environment synchronises the schema from the entities, so it is stubbed here. It has its + * own tests in `test/unit/oauth/config/oauth-config-validation.test.ts`. + */ +jest.mock('../../../../src/app/oauth/config/oauth-config-validation', () => ({ + validateOAuthConfiguration: jest.fn(), +})); + +import { encryptUtils } from '@openops/server-shared'; +import { PrincipalType } from '@openops/shared'; +import { FastifyInstance } from 'fastify'; +import { StatusCodes } from 'http-status-codes'; +import { databaseConnection } from '../../../../src/app/database/database-connection'; +import { setupServer } from '../../../../src/app/server'; +import { generateMockToken } from '../../../helpers/auth'; + +let app: FastifyInstance | null = null; + +const REGISTERED_REDIRECT = 'http://127.0.0.1:41100/callback'; +const CODE_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; + +async function registerClient(): Promise { + const response = await app!.inject({ + method: 'POST', + url: '/v1/oauth/register', + body: { + client_name: 'Route Test Client', + redirect_uris: [REGISTERED_REDIRECT], + }, + }); + + return response.json().client_id; +} + +function authorizeUrl(params: Record): string { + return `/v1/oauth/authorize?${new URLSearchParams(params).toString()}`; +} + +// Set for this suite only and put back afterwards: Jest reuses a worker across files, so +// leaving OAuth enabled would fail a later suite on the SQLite config guard. +const OVERRIDES: Record = { + OPS_OAUTH_ENABLED: 'true', + OPS_OAUTH_ISSUER_URL: 'http://localhost:3000', + OPS_MCP_RESOURCE_URL: 'http://localhost:3020/mcp', + OPS_OAUTH_RS_CLIENT_SECRET: 'r'.repeat(32), +}; + +const previousEnv = new Map(); + +beforeAll(async () => { + for (const [key, value] of Object.entries(OVERRIDES)) { + previousEnv.set(key, process.env[key]); + process.env[key] = value; + } + + encryptUtils.loadEncryptionKey(); + await databaseConnection().initialize(); + app = await setupServer(); +}); + +afterAll(async () => { + await app?.close(); + await databaseConnection().destroy(); + + for (const [key, value] of previousEnv) { + if (value === undefined) { + Reflect.deleteProperty(process.env, key); + } else { + process.env[key] = value; + } + } +}); + +describe('OAuth routes', () => { + describe('principal boundaries', () => { + it('refuses a SERVICE principal on the connected-apps list', async () => { + const token = await generateMockToken({ type: PrincipalType.SERVICE }); + + const response = await app!.inject({ + method: 'GET', + url: '/v1/oauth/grants', + headers: { authorization: `Bearer ${token}` }, + }); + + // A connection must not be able to enumerate or revoke its siblings. + expect(response.statusCode).toBe(StatusCodes.FORBIDDEN); + }); + + it('refuses a SERVICE principal on revocation', async () => { + const token = await generateMockToken({ type: PrincipalType.SERVICE }); + + const response = await app!.inject({ + method: 'DELETE', + url: '/v1/oauth/grants/some-grant-id', + headers: { authorization: `Bearer ${token}` }, + }); + + expect(response.statusCode).toBe(StatusCodes.FORBIDDEN); + }); + + it('admits a SERVICE principal on the projects list', async () => { + const token = await generateMockToken({ type: PrincipalType.SERVICE }); + + const response = await app!.inject({ + method: 'GET', + url: '/v1/oauth/projects', + headers: { authorization: `Bearer ${token}` }, + }); + + // The one route a connection calls about itself, so SERVICE is admitted here and + // refused on the two above. + expect(response.statusCode).not.toBe(StatusCodes.FORBIDDEN); + }); + + it('refuses an unauthenticated caller on the connected-apps list', async () => { + const response = await app!.inject({ + method: 'GET', + url: '/v1/oauth/grants', + }); + + expect(response.statusCode).toBe(StatusCodes.UNAUTHORIZED); + }); + }); + + describe('consent decision', () => { + it('refuses a decision that carries no anti-CSRF header', async () => { + const token = await generateMockToken({ type: PrincipalType.USER }); + + const response = await app!.inject({ + method: 'POST', + url: '/v1/oauth/requests/any-request-id/decision', + headers: { authorization: `Bearer ${token}` }, + body: { approve: true }, + }); + + // A cross-site form post cannot set a custom header, so losing this check would make + // consent forgeable against a signed-in user. + expect(response.statusCode).toBe(StatusCodes.BAD_REQUEST); + expect(response.json()).toMatchObject({ + error: 'invalid_request', + error_description: expect.stringContaining('x-openops-consent'), + }); + }); + + it('gets past the header check with it present, failing on the request id instead', async () => { + const token = await generateMockToken({ type: PrincipalType.USER }); + + const response = await app!.inject({ + method: 'POST', + url: '/v1/oauth/requests/any-request-id/decision', + headers: { + authorization: `Bearer ${token}`, + 'x-openops-consent': '1', + }, + body: { approve: true }, + }); + + // Proves the test above is about the header, not the route rejecting everything. + expect(response.json().error_description).not.toContain( + 'x-openops-consent', + ); + }); + }); + + describe('error envelope', () => { + it('answers an unsupported grant with an RFC 6749 body and 400', async () => { + const response = await app!.inject({ + method: 'POST', + url: '/v1/oauth/token', + payload: 'grant_type=implicit', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + }); + + // Clients branch on `error`, so these routes must not use the application envelope. + expect(response.statusCode).toBe(StatusCodes.BAD_REQUEST); + expect(response.json()).toMatchObject({ + error: 'unsupported_grant_type', + }); + expect(response.json()).not.toHaveProperty('code'); + }); + + it('keeps token failures out of caches', async () => { + const response = await app!.inject({ + method: 'POST', + url: '/v1/oauth/token', + payload: 'grant_type=implicit', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + }); + + expect(response.headers['cache-control']).toContain('no-store'); + }); + }); + + describe('authorize', () => { + it('renders an error for an unregistered redirect_uri instead of redirecting', async () => { + const clientId = await registerClient(); + + const response = await app!.inject({ + method: 'GET', + url: authorizeUrl({ + client_id: clientId, + redirect_uri: 'https://attacker.example/steal', + response_type: 'code', + code_challenge: CODE_CHALLENGE, + code_challenge_method: 'S256', + resource: 'http://localhost:3020/mcp', + }), + }); + + // Redirecting here would hand an attacker an open redirect on an endpoint whose + // whole job is to send browsers somewhere. + expect(response.statusCode).toBe(StatusCodes.BAD_REQUEST); + expect(response.headers.location).toBeUndefined(); + }); + + it('keeps rendered authorize errors out of caches', async () => { + const response = await app!.inject({ + method: 'GET', + url: authorizeUrl({ + client_id: 'not-a-registered-client', + redirect_uri: REGISTERED_REDIRECT, + response_type: 'code', + code_challenge: CODE_CHALLENGE, + code_challenge_method: 'S256', + resource: 'http://localhost:3020/mcp', + }), + }); + + // A public endpoint whose error page echoes request-derived text; an intermediary + // must not serve it to anyone else. + expect(response.statusCode).toBe(StatusCodes.BAD_REQUEST); + expect(response.headers['cache-control']).toContain('no-store'); + }); + + it('renders an error for an unknown client instead of redirecting', async () => { + const response = await app!.inject({ + method: 'GET', + url: authorizeUrl({ + client_id: 'not-a-registered-client', + redirect_uri: REGISTERED_REDIRECT, + response_type: 'code', + code_challenge: CODE_CHALLENGE, + code_challenge_method: 'S256', + resource: 'http://localhost:3020/mcp', + }), + }); + + expect(response.statusCode).toBe(StatusCodes.BAD_REQUEST); + expect(response.headers.location).toBeUndefined(); + }); + + it('redirects a validated request to the consent screen', async () => { + const clientId = await registerClient(); + + const response = await app!.inject({ + method: 'GET', + url: authorizeUrl({ + client_id: clientId, + redirect_uri: REGISTERED_REDIRECT, + response_type: 'code', + code_challenge: CODE_CHALLENGE, + code_challenge_method: 'S256', + resource: 'http://localhost:3020/mcp', + }), + }); + + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY); + expect(response.headers.location).toContain('/settings/connected-apps'); + expect(response.headers.location).toContain('request_id='); + }); + + it('sends a validated client back to its own redirect_uri when PKCE is missing', async () => { + const clientId = await registerClient(); + + const response = await app!.inject({ + method: 'GET', + url: authorizeUrl({ + client_id: clientId, + redirect_uri: REGISTERED_REDIRECT, + response_type: 'code', + resource: 'http://localhost:3020/mcp', + state: 'state-value', + }), + }); + + // Once the client and its redirect_uri are known good, errors go back to the + // client — carrying `state` and `iss` — rather than being rendered. + expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY); + expect(response.headers.location).toContain(REGISTERED_REDIRECT); + expect(response.headers.location).toContain('error=invalid_request'); + expect(response.headers.location).toContain('state=state-value'); + expect(response.headers.location).toContain('iss='); + }); + }); + + describe('discovery', () => { + it('serves authorization server metadata unauthenticated', async () => { + const response = await app!.inject({ + method: 'GET', + url: '/.well-known/oauth-authorization-server', + }); + + expect(response.statusCode).toBe(StatusCodes.OK); + expect(response.json()).toMatchObject({ + issuer: 'http://localhost:3000', + code_challenge_methods_supported: ['S256'], + }); + }); + + it('advertises only endpoints it actually serves', async () => { + const metadata = ( + await app!.inject({ + method: 'GET', + url: '/.well-known/oauth-authorization-server', + }) + ).json(); + + // Each with the method a client would really use: a 404 here means the document + // promises something the server does not answer. + const probes: [string, 'GET' | 'POST'][] = [ + [metadata.authorization_endpoint, 'GET'], + [metadata.token_endpoint, 'POST'], + [metadata.registration_endpoint, 'POST'], + [metadata.revocation_endpoint, 'POST'], + [metadata.jwks_uri, 'GET'], + ]; + + for (const [endpoint, method] of probes) { + const probe = await app!.inject({ + method, + url: new URL(endpoint).pathname, + }); + + expect({ endpoint, status: probe.statusCode }).not.toMatchObject({ + status: StatusCodes.NOT_FOUND, + }); + } + }); + + it('serves a JWKS with a usable signing key at the advertised location', async () => { + const metadata = ( + await app!.inject({ + method: 'GET', + url: '/.well-known/oauth-authorization-server', + }) + ).json(); + + const response = await app!.inject({ + method: 'GET', + url: new URL(metadata.jwks_uri).pathname, + }); + + expect(response.statusCode).toBe(StatusCodes.OK); + expect(response.json().keys[0]).toMatchObject({ + kty: 'RSA', + alg: 'RS256', + kid: expect.any(String), + }); + }); + }); +}); diff --git a/packages/server/api/test/unit/oauth/authorization/authorize-validation.test.ts b/packages/server/api/test/unit/oauth/authorization/authorize-validation.test.ts new file mode 100644 index 0000000000..7ad9bf7f6d --- /dev/null +++ b/packages/server/api/test/unit/oauth/authorization/authorize-validation.test.ts @@ -0,0 +1,232 @@ +import crypto from 'node:crypto'; +import { + AuthorizeQuery, + validateAuthorizeRequest, +} from '../../../../src/app/oauth/authorization/authorize-validation'; +import { oauthConfig } from '../../../../src/app/oauth/config/oauth-config'; +import { OAuthClient } from '../../../../src/app/oauth/storage/oauth-model'; + +const API_URI = 'https://ops.example.com/api'; +const MCP_URI = 'https://ops.example.com/mcp'; +const REGISTERED = 'https://client.example/cb'; +const CHALLENGE = crypto + .createHash('sha256') + .update('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk') + .digest('base64url'); + +const CLIENT: OAuthClient = { + id: 'client-1', + created: new Date().toISOString(), + updated: new Date().toISOString(), + clientName: 'Claude Code', + redirectUris: [REGISTERED, 'http://127.0.0.1:1234/callback'], + grantTypes: ['authorization_code', 'refresh_token'], + tokenEndpointAuthMethod: 'none', + clientSecretHash: null, +}; + +function query(overrides: Record = {}): AuthorizeQuery { + return { + client_id: 'client-1', + redirect_uri: REGISTERED, + response_type: 'code', + code_challenge: CHALLENGE, + code_challenge_method: 'S256', + resource: MCP_URI, + ...overrides, + }; +} + +describe('validateAuthorizeRequest', () => { + beforeEach(() => { + jest.spyOn(oauthConfig, 'getApiAudience').mockReturnValue(API_URI); + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(MCP_URI); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('accepts a well-formed request and returns the validated values', () => { + expect(validateAuthorizeRequest(query({ state: 'xyz' }), CLIENT)).toEqual({ + kind: 'ok', + resource: expect.objectContaining({ id: 'mcp', canonicalUri: MCP_URI }), + scope: 'mcp', + redirectUri: REGISTERED, + codeChallenge: CHALLENGE, + state: 'xyz', + }); + }); + + it('defaults the scope to what the resource offers', () => { + const result = validateAuthorizeRequest(query(), CLIENT); + + expect(result).toMatchObject({ kind: 'ok', scope: 'mcp', state: null }); + }); + + describe('refuses to redirect when the destination cannot be trusted', () => { + // The open-redirect boundary: a caller must never turn a `render_error` into a + // redirect. + 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', () => { + // `qs` turns `scope[x]=1` into an object; treating that as a string would surface as + // a 500 rather than an OAuth error. + 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/authorization/pending-authorization.service.test.ts b/packages/server/api/test/unit/oauth/authorization/pending-authorization.service.test.ts new file mode 100644 index 0000000000..4d72d6100e --- /dev/null +++ b/packages/server/api/test/unit/oauth/authorization/pending-authorization.service.test.ts @@ -0,0 +1,328 @@ +import { OAuthError } from '../../../../src/app/oauth/common/oauth-errors'; +import { OAuthPendingAuthorization } from '../../../../src/app/oauth/storage/oauth-model'; + +type PendingRow = OAuthPendingAuthorization; + +const rows: PendingRow[] = []; + +type Criteria = Record; + +// `consumedAt: IsNull()` arrives as a TypeORM `FindOperator`. Honouring it is what makes +// the single-use assertions real: ignoring it would report every update as affecting a row. +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/authorization/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/authorization/pkce.test.ts b/packages/server/api/test/unit/oauth/authorization/pkce.test.ts new file mode 100644 index 0000000000..c481c2cc0f --- /dev/null +++ b/packages/server/api/test/unit/oauth/authorization/pkce.test.ts @@ -0,0 +1,48 @@ +import crypto from 'node:crypto'; +import { + isValidCodeChallenge, + verifyPkce, +} from '../../../../src/app/oauth/authorization/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/authorization/redirect-uri.test.ts b/packages/server/api/test/unit/oauth/authorization/redirect-uri.test.ts new file mode 100644 index 0000000000..abfd28e683 --- /dev/null +++ b/packages/server/api/test/unit/oauth/authorization/redirect-uri.test.ts @@ -0,0 +1,100 @@ +import { + isRegistrableRedirectUri, + matchesRegisteredRedirectUri, +} from '../../../../src/app/oauth/authorization/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/clients/clients.service.test.ts b/packages/server/api/test/unit/oauth/clients/clients.service.test.ts new file mode 100644 index 0000000000..62325fbb46 --- /dev/null +++ b/packages/server/api/test/unit/oauth/clients/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/clients.service'; +import { sha256Hex } from '../../../../src/app/oauth/common/oauth-crypto'; +import { OAuthError } from '../../../../src/app/oauth/common/oauth-errors'; +import { oauthConfig } from '../../../../src/app/oauth/config/oauth-config'; +import { OAuthClient } from '../../../../src/app/oauth/storage/oauth-model'; + +const RS_SECRET = 'a'.repeat(48); + +const validMetadata = () => ({ + client_name: 'Test MCP Client', + redirect_uris: ['https://client.example.com/callback'], +}); + +const basicHeader = (clientId: string, secret: string): string => + `Basic ${Buffer.from(`${clientId}:${secret}`).toString('base64')}`; + +const storedRow = (id: string): ClientRow => { + const row = clientRows.find((candidate) => candidate.id === id); + if (!row) { + throw new Error(`expected a stored client row for ${id}`); + } + return row; +}; + +describe('clientsService', () => { + beforeEach(() => { + clientRows.length = 0; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('registerClient', () => { + it('registers a public client with defaults and no secret', async () => { + const response = await clientsService.registerClient(validMetadata()); + + expect(response.client_id).toEqual(expect.any(String)); + expect(response.client_id).toHaveLength(21); + expect(response.client_name).toBe('Test MCP Client'); + expect(response.redirect_uris).toEqual([ + 'https://client.example.com/callback', + ]); + expect(response.grant_types).toEqual([ + 'authorization_code', + 'refresh_token', + ]); + expect(response.token_endpoint_auth_method).toBe('none'); + expect(response.client_id_issued_at).toBeLessThanOrEqual( + Math.floor(Date.now() / 1000), + ); + + expect(JSON.stringify(response)).not.toContain('client_secret'); + expect( + Object.keys(response).filter((key) => key.includes('secret')), + ).toEqual([]); + + const row = storedRow(response.client_id); + expect(row.clientSecretHash).toBeNull(); + expect(row.tokenEndpointAuthMethod).toBe('none'); + expect(row.grantTypes).toEqual(['authorization_code', 'refresh_token']); + // No client-level usage column: usage is tracked per connection on the grant. + expect('lastUsedAt' in row).toBe(false); + // And no scope: what a token gets is decided by the resource it names. + expect('scope' in row).toBe(false); + expect('scope' in response).toBe(false); + }); + + it('persists an explicitly requested subset of grant types', async () => { + const response = await clientsService.registerClient({ + ...validMetadata(), + grant_types: ['authorization_code'], + }); + + expect(response.grant_types).toEqual(['authorization_code']); + expect(storedRow(response.client_id).grantTypes).toEqual([ + 'authorization_code', + ]); + }); + + it('ignores a requested scope rather than storing it', async () => { + const response = await clientsService.registerClient({ + ...validMetadata(), + scope: 'mcp api something-invented', + }); + + // Accepted rather than refused, but neither stored nor echoed. + expect('scope' in storedRow(response.client_id)).toBe(false); + expect('scope' in response).toBe(false); + }); + + it('rejects a missing client_name', async () => { + await expect( + clientsService.registerClient({ + redirect_uris: ['https://client.example.com/callback'], + }), + ).rejects.toThrow(OAuthError); + expect(clientRows).toHaveLength(0); + }); + + it('rejects an empty client_name', async () => { + await expect( + clientsService.registerClient({ ...validMetadata(), client_name: '' }), + ).rejects.toThrow('invalid_client_metadata'); + }); + + it('rejects a client_name over 128 characters', async () => { + await expect( + clientsService.registerClient({ + ...validMetadata(), + client_name: 'n'.repeat(129), + }), + ).rejects.toThrow('invalid_client_metadata'); + }); + + it('rejects missing redirect_uris', async () => { + await expect( + clientsService.registerClient({ client_name: 'Test MCP Client' }), + ).rejects.toThrow('invalid_redirect_uri'); + }); + + it('rejects an empty redirect_uris array', async () => { + await expect( + clientsService.registerClient({ + ...validMetadata(), + redirect_uris: [], + }), + ).rejects.toThrow('invalid_redirect_uri'); + }); + + it('rejects more than ten redirect_uris', async () => { + await expect( + clientsService.registerClient({ + ...validMetadata(), + redirect_uris: Array.from( + { length: 11 }, + (_unused, index) => `https://client.example.com/cb/${index}`, + ), + }), + ).rejects.toThrow('invalid_redirect_uri'); + }); + + it('rejects a non-loopback http redirect_uri', async () => { + await expect( + clientsService.registerClient({ + ...validMetadata(), + redirect_uris: ['http://attacker.example.com/callback'], + }), + ).rejects.toThrow('invalid_redirect_uri'); + expect(clientRows).toHaveLength(0); + }); + + it('rejects the implicit grant type', async () => { + await expect( + clientsService.registerClient({ + ...validMetadata(), + grant_types: ['implicit'], + }), + ).rejects.toThrow('invalid_client_metadata'); + }); + + it('rejects a registration that asks for the token-exchange grant', async () => { + await expect( + clientsService.registerClient({ + ...validMetadata(), + grant_types: ['authorization_code', TOKEN_EXCHANGE_GRANT], + }), + ).rejects.toThrow('invalid_client_metadata'); + expect(clientRows).toHaveLength(0); + }); + + it('rejects client_secret_basic authentication for a registered client', async () => { + await expect( + clientsService.registerClient({ + ...validMetadata(), + token_endpoint_auth_method: 'client_secret_basic', + }), + ).rejects.toThrow('invalid_client_metadata'); + expect(clientRows).toHaveLength(0); + }); + }); + + describe('getClient / getClientOrThrow', () => { + it('returns null for an unknown client and the row for a known one', async () => { + const registered = await clientsService.registerClient(validMetadata()); + + expect(await clientsService.getClient('does-not-exist')).toBeNull(); + + const found = await clientsService.getClient(registered.client_id); + expect(found?.id).toBe(registered.client_id); + expect(found?.clientName).toBe('Test MCP Client'); + }); + + it('throws invalid_client when the client is unknown', async () => { + await expect( + clientsService.getClientOrThrow('does-not-exist'), + ).rejects.toThrow('unknown client'); + }); + + it('returns the client when it exists', async () => { + const registered = await clientsService.registerClient(validMetadata()); + + const client = await clientsService.getClientOrThrow( + registered.client_id, + ); + + expect(client.id).toBe(registered.client_id); + expect(client.redirectUris).toEqual([ + 'https://client.example.com/callback', + ]); + }); + }); + + describe('assertGrantTypeAllowed', () => { + const clientWith = (grantTypes: string[]): OAuthClient => + ({ + id: 'client-1', + created: '2026-01-01T00:00:00.000Z', + updated: '2026-01-01T00:00:00.000Z', + clientName: 'Test MCP Client', + redirectUris: ['https://client.example.com/callback'], + grantTypes, + tokenEndpointAuthMethod: 'none', + clientSecretHash: null, + } as OAuthClient); + + it('allows a grant type the client registered', () => { + expect(() => + clientsService.assertGrantTypeAllowed( + clientWith(['authorization_code', 'refresh_token']), + 'refresh_token', + ), + ).not.toThrow(); + }); + + it('rejects a grant type the client did not register', () => { + expect(() => + clientsService.assertGrantTypeAllowed( + clientWith(['authorization_code']), + 'refresh_token', + ), + ).toThrow('unauthorized_client'); + }); + + it('rejects the token-exchange grant for a public client', () => { + expect(() => + clientsService.assertGrantTypeAllowed( + clientWith(['authorization_code', 'refresh_token']), + TOKEN_EXCHANGE_GRANT, + ), + ).toThrow('unauthorized_client'); + }); + }); + + describe('ensureResourceServerClient', () => { + it('does nothing when no resource server secret is configured', async () => { + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(undefined); + + await clientsService.ensureResourceServerClient(); + + expect(clientRows).toHaveLength(0); + }); + + it('does nothing when the configured secret is an empty string', async () => { + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(''); + + await clientsService.ensureResourceServerClient(); + + expect(clientRows).toHaveLength(0); + }); + + it('fails fast when the configured secret is too short', async () => { + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue('a'.repeat(31)); + + // A configuration fault, reported as one — not as an OAuth protocol error. + await expect(clientsService.ensureResourceServerClient()).rejects.toThrow( + 'SYSTEM_PROP_INVALID', + ); + expect(clientRows).toHaveLength(0); + }); + + it('creates the resource server client with only the hashed secret', async () => { + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(RS_SECRET); + + await clientsService.ensureResourceServerClient(); + + expect(clientRows).toHaveLength(1); + const row = storedRow(RS_CLIENT_ID); + expect(row.clientName).toBe('OpenOps MCP Resource Server'); + expect(row.redirectUris).toEqual([]); + expect(row.grantTypes).toEqual([TOKEN_EXCHANGE_GRANT]); + expect(row.tokenEndpointAuthMethod).toBe('client_secret_basic'); + expect(row.clientSecretHash).toBe(sha256Hex(RS_SECRET)); + expect(JSON.stringify(row)).not.toContain(RS_SECRET); + }); + + it('keeps the row id within the 21-character id column limit', async () => { + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(RS_SECRET); + + await clientsService.ensureResourceServerClient(); + + expect(RS_CLIENT_ID.length).toBeLessThanOrEqual(21); + expect(String(storedRow(RS_CLIENT_ID).id).length).toBeLessThanOrEqual(21); + }); + + it('is idempotent across repeated boots', async () => { + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(RS_SECRET); + + await clientsService.ensureResourceServerClient(); + const created = storedRow(RS_CLIENT_ID).created; + await clientsService.ensureResourceServerClient(); + + expect(clientRows).toHaveLength(1); + expect(storedRow(RS_CLIENT_ID).created).toBe(created); + expect(storedRow(RS_CLIENT_ID).clientSecretHash).toBe( + sha256Hex(RS_SECRET), + ); + }); + + it('updates the stored hash when the configured secret is rotated', async () => { + const rotatedSecret = 'b'.repeat(48); + const secretSpy = jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(RS_SECRET); + + await clientsService.ensureResourceServerClient(); + secretSpy.mockReturnValue(rotatedSecret); + await clientsService.ensureResourceServerClient(); + + expect(clientRows).toHaveLength(1); + expect(storedRow(RS_CLIENT_ID).clientSecretHash).toBe( + sha256Hex(rotatedSecret), + ); + + await expect( + clientsService.authenticateResourceServerClient( + basicHeader(RS_CLIENT_ID, RS_SECRET), + ), + ).rejects.toThrow('invalid_client'); + const client = await clientsService.authenticateResourceServerClient( + basicHeader(RS_CLIENT_ID, rotatedSecret), + ); + expect(client.id).toBe(RS_CLIENT_ID); + }); + }); + + describe('authenticateResourceServerClient', () => { + beforeEach(async () => { + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(RS_SECRET); + await clientsService.ensureResourceServerClient(); + }); + + it('authenticates the resource server with correct Basic credentials', async () => { + const client = await clientsService.authenticateResourceServerClient( + basicHeader(RS_CLIENT_ID, RS_SECRET), + ); + + expect(client.id).toBe(RS_CLIENT_ID); + expect(client.grantTypes).toEqual([TOKEN_EXCHANGE_GRANT]); + expect(client.tokenEndpointAuthMethod).toBe('client_secret_basic'); + }); + + it('accepts a lowercase basic scheme', async () => { + const header = basicHeader(RS_CLIENT_ID, RS_SECRET).replace( + 'Basic ', + 'basic ', + ); + + const client = await clientsService.authenticateResourceServerClient( + header, + ); + + expect(client.id).toBe(RS_CLIENT_ID); + }); + + it('accepts a secret containing colons and percent-encoding', async () => { + const secret = 'aaaa:bbbb:cccc dddd/eeee-ffff-gggg-hhhh-iiii-jjjj'; + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(secret); + await clientsService.ensureResourceServerClient(); + + const header = `Basic ${Buffer.from( + `${RS_CLIENT_ID}:${encodeURIComponent(secret)}`, + ).toString('base64')}`; + + const client = await clientsService.authenticateResourceServerClient( + header, + ); + + expect(client.id).toBe(RS_CLIENT_ID); + }); + + it('tolerates a malformed percent escape in the secret', async () => { + const secret = `100%-literal-secret-${'z'.repeat(20)}`; + jest + .spyOn(oauthConfig, 'getResourceServerClientSecret') + .mockReturnValue(secret); + await clientsService.ensureResourceServerClient(); + + const client = await clientsService.authenticateResourceServerClient( + basicHeader(RS_CLIENT_ID, secret), + ); + + expect(client.id).toBe(RS_CLIENT_ID); + }); + + it('rejects a wrong secret', async () => { + await expect( + clientsService.authenticateResourceServerClient( + basicHeader(RS_CLIENT_ID, 'c'.repeat(48)), + ), + ).rejects.toThrow('invalid_client'); + }); + + it('rejects a missing Authorization header', async () => { + await expect( + clientsService.authenticateResourceServerClient(undefined), + ).rejects.toThrow('missing client credentials'); + }); + + it('rejects a non-Basic Authorization header', async () => { + await expect( + clientsService.authenticateResourceServerClient('Bearer some-token'), + ).rejects.toThrow('missing client credentials'); + }); + + it('rejects a public DCR client even when its id is known', async () => { + const registered = await clientsService.registerClient(validMetadata()); + + await expect( + clientsService.authenticateResourceServerClient( + basicHeader(registered.client_id, RS_SECRET), + ), + ).rejects.toThrow('invalid_client'); + }); + + it('does not reveal whether the client id or the secret was wrong', async () => { + const unknownClient = await clientsService + .authenticateResourceServerClient( + basicHeader('unknown-client', RS_SECRET), + ) + .catch((error: OAuthError) => error); + const wrongSecret = await clientsService + .authenticateResourceServerClient( + basicHeader(RS_CLIENT_ID, 'c'.repeat(48)), + ) + .catch((error: OAuthError) => error); + + expect(unknownClient).toBeInstanceOf(OAuthError); + expect(wrongSecret).toBeInstanceOf(OAuthError); + expect((unknownClient as OAuthError).errorCode).toBe('invalid_client'); + expect((unknownClient as OAuthError).statusCode).toBe(401); + expect((unknownClient as OAuthError).description).toBe( + (wrongSecret as OAuthError).description, + ); + expect((unknownClient as OAuthError).description).not.toContain( + RS_CLIENT_ID, + ); + expect((unknownClient as OAuthError).description).not.toMatch( + /unknown|not found|secret|password/i, + ); + }); + }); +}); diff --git a/packages/server/api/test/unit/oauth/clients/grants.service.test.ts b/packages/server/api/test/unit/oauth/clients/grants.service.test.ts new file mode 100644 index 0000000000..0dec1bdec7 --- /dev/null +++ b/packages/server/api/test/unit/oauth/clients/grants.service.test.ts @@ -0,0 +1,366 @@ +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. + 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/clients/grants.service'; + +const BASE_PARAMS = { + clientId: 'client-1', + userId: 'user-1', + resourceId: 'mcp', +}; + +function seedRefreshToken(overrides: Row = {}): Row { + const row: Row = { + id: `refresh-${refreshTokenRows.length + 1}`, + tokenHash: `hash-${refreshTokenRows.length + 1}`, + grantId: 'grant-1', + familyId: 'family-1', + clientId: 'client-1', + resource: 'https://ops.example.com/mcp', + scope: 'mcp', + expiresAt: new Date(Date.now() + 86_400_000).toISOString(), + revokedAt: null, + ...overrides, + }; + refreshTokenRows.push(row); + return row; +} + +describe('grantsService', () => { + beforeEach(() => { + grantRows.length = 0; + refreshTokenRows.length = 0; + grantsService.clearSnapshotCacheForTests(); + }); + + describe('create', () => { + it('creates an active grant on the default project', async () => { + const grant = await grantsService.create(BASE_PARAMS); + + expect(grantRows).toHaveLength(1); + expect(grant).toMatchObject({ + clientId: 'client-1', + userId: 'user-1', + resourceId: 'mcp', + status: 'active', + revokedAt: null, + }); + // A scope column would restate resourceId, since each resource grants exactly one. + expect('scope' in (grantRows[0] as object)).toBe(false); + }); + + it('creates an independent grant each time the same client is authorized', async () => { + const first = await grantsService.create(BASE_PARAMS); + const second = await grantsService.create(BASE_PARAMS); + + expect(second.id).not.toBe(first.id); + expect(grantRows).toHaveLength(2); + expect(grantRows.every((row) => row.status === 'active')).toBe(true); + }); + + it('revoking one connection leaves the user other connections intact', async () => { + const first = await grantsService.create(BASE_PARAMS); + const second = await grantsService.create(BASE_PARAMS); + const firstToken = seedRefreshToken({ grantId: first.id }); + const secondToken = seedRefreshToken({ grantId: second.id }); + + await grantsService.revoke(first.id); + + expect(await grantsService.getGrantSnapshot(first.id)).toMatchObject({ + status: 'revoked', + }); + expect(await grantsService.getGrantSnapshot(second.id)).toMatchObject({ + status: 'active', + }); + expect(firstToken.revokedAt).toEqual(expect.any(String)); + expect(secondToken.revokedAt).toBeNull(); + }); + + it('records no project on the grant', async () => { + const grant = await grantsService.create(BASE_PARAMS); + + // A connection can switch project, so it belongs to the credential chain (the + // refresh token). A copy here could only be where the connection started. + expect('projectId' in (grant as object)).toBe(false); + expect( + 'setActiveProject' in (grantsService as Record), + ).toBe(false); + }); + + it('creates separate grants per client and per user', async () => { + await grantsService.create(BASE_PARAMS); + await grantsService.create({ + ...BASE_PARAMS, + clientId: 'client-2', + }); + await grantsService.create({ + ...BASE_PARAMS, + userId: 'user-2', + }); + + expect(grantRows).toHaveLength(3); + }); + }); + + describe('revoke', () => { + it('marks the grant revoked and cascades to its unrevoked refresh tokens', async () => { + const grant = await grantsService.create(BASE_PARAMS); + const tokenA = seedRefreshToken({ grantId: grant.id }); + const tokenB = seedRefreshToken({ grantId: grant.id }); + const otherGrantToken = seedRefreshToken({ grantId: 'other-grant' }); + + await grantsService.revoke(grant.id); + + expect(grantRows[0]).toMatchObject({ status: 'revoked' }); + expect(grantRows[0].revokedAt).toEqual(expect.any(String)); + expect(tokenA.revokedAt).toEqual(expect.any(String)); + expect(tokenB.revokedAt).toEqual(expect.any(String)); + expect(otherGrantToken.revokedAt).toBeNull(); + }); + + it('leaves an already-revoked token timestamp untouched', async () => { + const grant = await grantsService.create(BASE_PARAMS); + const earlier = '2020-01-01T00:00:00.000Z'; + const alreadyRevoked = seedRefreshToken({ + grantId: grant.id, + revokedAt: earlier, + }); + + await grantsService.revoke(grant.id); + + expect(alreadyRevoked.revokedAt).toBe(earlier); + }); + + it('busts the snapshot cache so revocation takes effect immediately', async () => { + const grant = await grantsService.create(BASE_PARAMS); + await grantsService.getGrantSnapshot(grant.id); + + await grantsService.revoke(grant.id); + + expect(await grantsService.getGrantSnapshot(grant.id)).toMatchObject({ + status: 'revoked', + }); + await expect( + grantsService.getActiveGrantOrThrow(grant.id), + ).rejects.toThrow('revoked'); + }); + }); + + describe('revokeForUser', () => { + it('revokes a grant the user owns', async () => { + const grant = await grantsService.create(BASE_PARAMS); + + await grantsService.revokeForUser(grant.id, 'user-1'); + + expect(grantRows[0]).toMatchObject({ status: 'revoked' }); + }); + + it("refuses to revoke another user's grant", async () => { + const grant = await grantsService.create(BASE_PARAMS); + + await expect( + grantsService.revokeForUser(grant.id, 'attacker'), + ).rejects.toThrow('unknown grant'); + expect(grantRows[0]).toMatchObject({ status: 'active' }); + }); + }); + + describe('getGrantSnapshot', () => { + it('returns undefined for an unknown grant', async () => { + expect(await grantsService.getGrantSnapshot('missing')).toBeUndefined(); + }); + + it('serves repeated reads from cache without hitting the store again', async () => { + const grant = await grantsService.create(BASE_PARAMS); + await grantsService.getGrantSnapshot(grant.id); + + // Mutate the row behind the service's back; the cached read must not see it. + grantRows[0].status = 'revoked'; + + expect(await grantsService.getGrantSnapshot(grant.id)).toMatchObject({ + status: 'active', + }); + + grantsService.clearSnapshotCacheForTests(); + + expect(await grantsService.getGrantSnapshot(grant.id)).toMatchObject({ + status: 'revoked', + }); + }); + + it('drops expired entries instead of growing forever', async () => { + // Each reconnect creates a new grant, and nothing evicts a key once its window + // passes, so without a sweep the map grows for the life of the process. + const grant = await grantsService.create(BASE_PARAMS); + await grantsService.getGrantSnapshot(grant.id); + + // Past the sweep threshold with ids never read again, as short-lived connections + // would leave behind. + 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 frees nothing if the working set really is that large; the cache is an + // optimization, so memory is bounded ahead of the query count. + for (let i = 0; i < 10_001; i++) { + await grantsService.getGrantSnapshot(`live-grant-${i}`); + } + + expect(grantsService.snapshotCacheSizeForTests()).toBeLessThanOrEqual( + 10_000, + ); + }); + + it('re-reads once the cache entry expires', async () => { + const grant = await grantsService.create(BASE_PARAMS); + await grantsService.getGrantSnapshot(grant.id); + grantRows[0].status = 'revoked'; + + const nowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(Date.now() + 61_000); + + expect(await grantsService.getGrantSnapshot(grant.id)).toMatchObject({ + status: 'revoked', + }); + + nowSpy.mockRestore(); + }); + }); + + describe('getActiveGrantOrThrow', () => { + it('returns the snapshot for an active grant', async () => { + const grant = await grantsService.create(BASE_PARAMS); + + expect(await grantsService.getActiveGrantOrThrow(grant.id)).toMatchObject( + { + id: grant.id, + userId: 'user-1', + status: 'active', + }, + ); + }); + + it('throws for an unknown grant', async () => { + await expect( + grantsService.getActiveGrantOrThrow('missing'), + ).rejects.toThrow('revoked'); + }); + }); + + describe('listForUser', () => { + it('lists only the active grants belonging to the user', async () => { + const mine = await grantsService.create(BASE_PARAMS); + await grantsService.create({ + ...BASE_PARAMS, + userId: 'user-2', + clientId: 'client-2', + }); + const revoked = await grantsService.create({ + ...BASE_PARAMS, + clientId: 'client-3', + }); + await grantsService.revoke(revoked.id); + + const grants = await grantsService.listForUser('user-1'); + + expect(grants.map((grant) => grant.id)).toEqual([mine.id]); + }); + }); + + describe('touch', () => { + it('records last usage on the first call', async () => { + const grant = await grantsService.create(BASE_PARAMS); + + await grantsService.touch(grant.id); + + expect(grantRows[0].lastUsedAt).toEqual(expect.any(String)); + }); + + it('throttles repeated writes within the interval', async () => { + const grant = await grantsService.create(BASE_PARAMS); + await grantsService.touch(grant.id); + const firstWrite = grantRows[0].lastUsedAt; + + grantRows[0].lastUsedAt = 'sentinel'; + await grantsService.touch(grant.id); + + expect(grantRows[0].lastUsedAt).toBe('sentinel'); + expect(firstWrite).toEqual(expect.any(String)); + }); + + it('writes again once the interval has passed', async () => { + const grant = await grantsService.create(BASE_PARAMS); + await grantsService.touch(grant.id); + grantRows[0].lastUsedAt = 'sentinel'; + + const nowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(Date.now() + 61_000); + await grantsService.touch(grant.id); + nowSpy.mockRestore(); + + expect(grantRows[0].lastUsedAt).not.toBe('sentinel'); + }); + }); +}); diff --git a/packages/server/api/test/unit/oauth/common/canonical-url.test.ts b/packages/server/api/test/unit/oauth/common/canonical-url.test.ts new file mode 100644 index 0000000000..4796fec275 --- /dev/null +++ b/packages/server/api/test/unit/oauth/common/canonical-url.test.ts @@ -0,0 +1,33 @@ +import { stripTrailingSlashes } from '../../../../src/app/oauth/common/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 took seconds on an input this size, reachable from an + // unauthenticated request. The budget is loose enough for a shared CI runner. + 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/common/oauth-crypto.test.ts b/packages/server/api/test/unit/oauth/common/oauth-crypto.test.ts new file mode 100644 index 0000000000..333356ee77 --- /dev/null +++ b/packages/server/api/test/unit/oauth/common/oauth-crypto.test.ts @@ -0,0 +1,36 @@ +import { + generateOpaqueToken, + sha256Hex, + timingSafeStringEqual, +} from '../../../../src/app/oauth/common/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/common/oauth-errors.test.ts b/packages/server/api/test/unit/oauth/common/oauth-errors.test.ts new file mode 100644 index 0000000000..c173def23d --- /dev/null +++ b/packages/server/api/test/unit/oauth/common/oauth-errors.test.ts @@ -0,0 +1,40 @@ +import { + invalidClient, + invalidGrant, + invalidRequest, + invalidTarget, + OAuthError, + serverError, +} from '../../../../src/app/oauth/common/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/config/oauth-config-validation.test.ts b/packages/server/api/test/unit/oauth/config/oauth-config-validation.test.ts new file mode 100644 index 0000000000..76c1380979 --- /dev/null +++ b/packages/server/api/test/unit/oauth/config/oauth-config-validation.test.ts @@ -0,0 +1,135 @@ +import { AppSystemProp, system } from '@openops/server-shared'; +import { oauthConfig } from '../../../../src/app/oauth/config/oauth-config'; +import { validateOAuthConfiguration } from '../../../../src/app/oauth/config/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', () => { + // Equal audiences would have the resource server accept API tokens, silently voiding + // the no-token-passthrough guarantee. + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(ISSUER); + + expect(() => validateOAuthConfiguration()).toThrow('must differ'); + }); + + it.each([ + ['a trailing slash', `${ISSUER}/`], + ['a different case in the host', 'https://OPS.example.com/api'], + ])('refuses an mcp resource that differs only by %s', (_label, mcpUrl) => { + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(mcpUrl); + + expect(() => validateOAuthConfiguration()).toThrow('must differ'); + }); + + it.each([ + ['a relative value', '/api'], + ['a non-URL', 'not a url'], + ['plain http on a public host', 'http://ops.example.com/api'], + ['a query string', 'https://ops.example.com/api?x=1'], + ['a fragment', 'https://ops.example.com/api#f'], + ])('refuses an issuer that is %s', (_label, issuer) => { + jest.spyOn(oauthConfig, 'getIssuerUrl').mockReturnValue(issuer); + jest.spyOn(oauthConfig, 'getApiAudience').mockReturnValue(issuer); + + expect(() => validateOAuthConfiguration()).toThrow('OPS_OAUTH_ISSUER_URL'); + }); + + it('refuses a malformed mcp resource url', () => { + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue('not a url'); + + expect(() => validateOAuthConfiguration()).toThrow('OPS_MCP_RESOURCE_URL'); + }); + + it('accepts the TTLs this repository ships as defaults', () => { + // Guards the bounds themselves: a range excluding the shipped defaults would fail + // every boot while the assertions below still looked 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. + expect(() => validateOAuthConfiguration()).toThrow(`OPS_${prop}`); + }, + ); + + it('refuses a fractional TTL rather than silently truncating it', () => { + jest.spyOn(oauthConfig, 'getAccessTokenTtlSeconds').mockReturnValue(900.5); + + expect(() => validateOAuthConfiguration()).toThrow('whole number'); + }); + + it('refuses to run on sqlite, where the migration is not registered', () => { + (system.get as jest.Mock).mockImplementation((prop: string) => + prop === AppSystemProp.DB_TYPE ? 'SQLITE3' : undefined, + ); + + expect(() => validateOAuthConfiguration()).toThrow('PostgreSQL'); + }); +}); diff --git a/packages/server/api/test/unit/oauth/config/oauth-config.test.ts b/packages/server/api/test/unit/oauth/config/oauth-config.test.ts new file mode 100644 index 0000000000..5ffb8c47c4 --- /dev/null +++ b/packages/server/api/test/unit/oauth/config/oauth-config.test.ts @@ -0,0 +1,71 @@ +import { AppSystemProp, system } from '@openops/server-shared'; +import { oauthConfig } from '../../../../src/app/oauth/config/oauth-config'; + +describe('oauthConfig', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('strips trailing slashes from the issuer', () => { + jest + .spyOn(system, 'getOrThrow') + .mockReturnValue('https://ops.example.com/api/'); + + expect(oauthConfig.getIssuerUrl()).toBe('https://ops.example.com/api'); + }); + + it('uses the issuer as the api audience', () => { + jest + .spyOn(system, 'getOrThrow') + .mockReturnValue('https://ops.example.com/api'); + + expect(oauthConfig.getApiAudience()).toBe('https://ops.example.com/api'); + }); + + it('normalizes the mcp resource url and returns undefined when unset', () => { + const getSpy = jest.spyOn(system, 'get'); + + getSpy.mockReturnValue('https://ops.example.com/mcp/'); + expect(oauthConfig.getMcpResourceUrl()).toBe('https://ops.example.com/mcp'); + + getSpy.mockReturnValue(undefined); + expect(oauthConfig.getMcpResourceUrl()).toBeUndefined(); + }); + + it('reads each TTL from its own setting', () => { + const getNumber = jest + .spyOn(system, 'getNumberOrThrow') + .mockReturnValue(42); + + expect(oauthConfig.getAccessTokenTtlSeconds()).toBe(42); + expect(getNumber).toHaveBeenLastCalledWith( + AppSystemProp.OAUTH_ACCESS_TOKEN_TTL_SECONDS, + ); + + expect(oauthConfig.getRefreshTokenTtlDays()).toBe(42); + expect(getNumber).toHaveBeenLastCalledWith( + AppSystemProp.OAUTH_REFRESH_TOKEN_TTL_DAYS, + ); + + expect(oauthConfig.getExchangeTokenTtlSeconds()).toBe(42); + expect(getNumber).toHaveBeenLastCalledWith( + AppSystemProp.OAUTH_EXCHANGE_TOKEN_TTL_SECONDS, + ); + }); + + it('is disabled unless explicitly enabled', () => { + // Driven through the mock, not the ambient environment: a local .env sets this, and + // the default when nothing does is what is under test. + const getBoolean = jest.spyOn(system, 'getBoolean'); + + getBoolean.mockReturnValue(undefined); + expect(oauthConfig.isEnabled()).toBe(false); + + getBoolean.mockReturnValue(false); + expect(oauthConfig.isEnabled()).toBe(false); + + getBoolean.mockReturnValue(true); + expect(oauthConfig.isEnabled()).toBe(true); + expect(getBoolean).toHaveBeenLastCalledWith(AppSystemProp.OAUTH_ENABLED); + }); +}); diff --git a/packages/server/api/test/unit/oauth/discovery/oauth-metadata.test.ts b/packages/server/api/test/unit/oauth/discovery/oauth-metadata.test.ts new file mode 100644 index 0000000000..2bd4f4df67 --- /dev/null +++ b/packages/server/api/test/unit/oauth/discovery/oauth-metadata.test.ts @@ -0,0 +1,103 @@ +import { oauthConfig } from '../../../../src/app/oauth/config/oauth-config'; +import { + buildAuthorizationServerMetadata, + getWellKnownPathVariants, +} from '../../../../src/app/oauth/discovery/oauth-metadata'; + +const ISSUER = 'https://ops.example.com/api'; +const MCP_URI = 'https://ops.example.com/mcp'; + +describe('buildAuthorizationServerMetadata', () => { + beforeEach(() => { + jest.spyOn(oauthConfig, 'getIssuerUrl').mockReturnValue(ISSUER); + jest.spyOn(oauthConfig, 'getApiAudience').mockReturnValue(ISSUER); + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(MCP_URI); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('advertises exactly the endpoints and capabilities that exist', () => { + expect(buildAuthorizationServerMetadata()).toEqual({ + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/v1/oauth/authorize`, + token_endpoint: `${ISSUER}/v1/oauth/token`, + registration_endpoint: `${ISSUER}/v1/oauth/register`, + revocation_endpoint: `${ISSUER}/v1/oauth/revoke`, + jwks_uri: `${ISSUER}/v1/oauth/jwks.json`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none', 'client_secret_basic'], + scopes_supported: ['api', 'mcp'], + authorization_response_iss_parameter_supported: true, + }); + }); + + it('claims no OpenID Connect capability, because none is implemented', () => { + const document = buildAuthorizationServerMetadata() as Record< + string, + unknown + >; + + for (const oidcOnlyField of [ + 'id_token_signing_alg_values_supported', + 'subject_types_supported', + 'userinfo_endpoint', + 'claims_supported', + ]) { + expect(document[oidcOnlyField]).toBeUndefined(); + } + }); + + it('offers no implicit or password grant', () => { + const { grant_types_supported, response_types_supported } = + buildAuthorizationServerMetadata(); + + expect(grant_types_supported).not.toContain('implicit'); + expect(grant_types_supported).not.toContain('password'); + expect(response_types_supported).not.toContain('token'); + }); + + it('never advertises plain PKCE', () => { + expect( + buildAuthorizationServerMetadata().code_challenge_methods_supported, + ).toEqual(['S256']); + }); + + it('drops the mcp scope when no mcp resource is deployed', () => { + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(undefined); + + expect(buildAuthorizationServerMetadata().scopes_supported).toEqual([ + 'api', + ]); + }); +}); + +describe('getWellKnownPathVariants', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('also serves the issuer path-aware location required by RFC 8414 §3', () => { + jest.spyOn(oauthConfig, 'getIssuerUrl').mockReturnValue(ISSUER); + + expect( + getWellKnownPathVariants('/.well-known/oauth-authorization-server'), + ).toEqual([ + '/.well-known/oauth-authorization-server', + '/.well-known/oauth-authorization-server/api', + ]); + }); + + it('serves only the root location when the issuer has no path', () => { + jest + .spyOn(oauthConfig, 'getIssuerUrl') + .mockReturnValue('https://ops.example.com'); + + expect( + getWellKnownPathVariants('/.well-known/oauth-authorization-server'), + ).toEqual(['/.well-known/oauth-authorization-server']); + }); +}); diff --git a/packages/server/api/test/unit/oauth/discovery/resource-registry.test.ts b/packages/server/api/test/unit/oauth/discovery/resource-registry.test.ts new file mode 100644 index 0000000000..c682c8a966 --- /dev/null +++ b/packages/server/api/test/unit/oauth/discovery/resource-registry.test.ts @@ -0,0 +1,60 @@ +import { oauthConfig } from '../../../../src/app/oauth/config/oauth-config'; +import { + getRegisteredResources, + getSupportedScopes, + resolveResource, +} from '../../../../src/app/oauth/discovery/resource-registry'; + +const API_URI = 'https://ops.example.com/api'; +const MCP_URI = 'https://ops.example.com/mcp'; + +describe('resource-registry', () => { + beforeEach(() => { + jest.spyOn(oauthConfig, 'getApiAudience').mockReturnValue(API_URI); + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(MCP_URI); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('registers the api and mcp resources with their audiences and scopes', () => { + expect(getRegisteredResources()).toEqual([ + { + id: 'api', + audience: API_URI, + canonicalUri: API_URI, + scopes: ['api'], + }, + { + id: 'mcp', + audience: MCP_URI, + canonicalUri: MCP_URI, + scopes: ['mcp'], + }, + ]); + }); + + it('omits the mcp resource when no mcp url is configured', () => { + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(undefined); + + expect(getRegisteredResources().map((r) => r.id)).toEqual(['api']); + expect(resolveResource(MCP_URI)).toBeUndefined(); + }); + + it('resolves a resource by canonical uri, tolerating a trailing slash', () => { + expect(resolveResource(MCP_URI)?.id).toBe('mcp'); + expect(resolveResource(`${MCP_URI}/`)?.id).toBe('mcp'); + expect(resolveResource(API_URI)?.id).toBe('api'); + }); + + it('does not resolve unknown or empty resources', () => { + expect(resolveResource('https://elsewhere.example.com')).toBeUndefined(); + expect(resolveResource('')).toBeUndefined(); + expect(resolveResource(`${MCP_URI}/extra`)).toBeUndefined(); + }); + + it('lists every supported scope', () => { + expect(getSupportedScopes()).toEqual(['api', 'mcp']); + }); +}); diff --git a/packages/server/api/test/unit/oauth/oauth-cleanup-job.test.ts b/packages/server/api/test/unit/oauth/oauth-cleanup-job.test.ts new file mode 100644 index 0000000000..0776d6ad87 --- /dev/null +++ b/packages/server/api/test/unit/oauth/oauth-cleanup-job.test.ts @@ -0,0 +1,285 @@ +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. Compared as instants, because the service +// binds cutoffs as `Date` objects — see `oauth-query.ts`. +function matches(row: Row, criteria: Row): boolean { + return Object.entries(criteria).every(([key, expected]) => { + if (isFindOperator(expected)) { + const actual = row[key]; + if (typeof actual !== 'string') { + return false; + } + const cutoff = expected.value; + if (!(cutoff instanceof Date)) { + throw new Error(`expected a Date cutoff for ${key}`); + } + return new Date(actual).getTime() < cutoff.getTime(); + } + return row[key] === expected; + }); +} + +function makeRepo(store: Row[]) { + return () => ({ + delete: async (criteria: Row) => { + const survivors = store.filter((row) => !matches(row, criteria)); + const affected = store.length - survivors.length; + store.length = 0; + store.push(...survivors); + return { affected }; + }, + }); +} + +jest.mock('../../../src/app/core/db/repo-factory', () => ({ + repoFactory: (entity: { options: { name: string } }) => { + switch (entity.options.name) { + case 'oauth_authorization_code': + return makeRepo(codeRows); + case 'oauth_pending_authorization': + return makeRepo(pendingRows); + case 'oauth_refresh_token': + return makeRepo(refreshRows); + case 'oauth_client': + return () => clientRepo; + case 'oauth_grant': + return () => grantRepo; + default: + throw new Error(`unexpected entity ${entity.options.name}`); + } + }, +})); + +const loggerInfo = jest.fn(); + +jest.mock('@openops/server-shared', () => ({ + ...jest.requireActual('@openops/server-shared'), + logger: { info: loggerInfo, warn: jest.fn(), error: jest.fn() }, +})); + +import { + OAUTH_CLEANUP_CRON, + oauthCleanupJobHandler, +} from '../../../src/app/oauth/oauth-cleanup-job'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function isoDaysAgo(days: number): string { + return new Date(Date.now() - days * DAY_MS).toISOString(); +} + +function isoInMinutes(minutes: number): string { + return new Date(Date.now() + minutes * 60 * 1000).toISOString(); +} + +describe('oauthCleanupJobHandler', () => { + beforeEach(() => { + codeRows.length = 0; + pendingRows.length = 0; + refreshRows.length = 0; + jest.clearAllMocks(); + clientQueryBuilder.delete.mockReturnValue(clientQueryBuilder); + clientQueryBuilder.where.mockReturnValue(clientQueryBuilder); + clientQueryBuilder.andWhere.mockReturnValue(clientQueryBuilder); + clientQueryBuilder.execute.mockResolvedValue({ affected: 2 }); + grantQueryBuilder.delete.mockReturnValue(grantQueryBuilder); + grantQueryBuilder.where.mockReturnValue(grantQueryBuilder); + grantQueryBuilder.andWhere.mockReturnValue(grantQueryBuilder); + grantQueryBuilder.execute.mockResolvedValue({ affected: 1 }); + }); + + it('deletes only dead connections: no live refresh token and unused for long enough', async () => { + await oauthCleanupJobHandler(); + + expect(grantQueryBuilder.execute).toHaveBeenCalled(); + const [dateClause, dateParams] = grantQueryBuilder.where.mock.calls[0]; + expect(dateClause).toContain('COALESCE("lastUsedAt", "created")'); + expect(dateParams.cutoff).toBeInstanceOf(Date); + expect((dateParams.cutoff as Date).getTime()).toBeLessThan(Date.now()); + // A connection with any unrevoked refresh token is still live and must survive. + expect(grantQueryBuilder.andWhere.mock.calls[0][0]).toContain( + 'NOT EXISTS (SELECT 1 FROM oauth_refresh_token t WHERE t."grantId" = oauth_grant.id AND t."revokedAt" IS NULL)', + ); + }); + + it('reports how many dead connections it removed', async () => { + await oauthCleanupJobHandler(); + + expect(loggerInfo).toHaveBeenCalledWith( + 'OAuth cleanup completed', + expect.objectContaining({ deadGrants: 1 }), + ); + }); + + it('runs hourly', () => { + expect(OAUTH_CLEANUP_CRON).toBe('0 * * * *'); + }); + + it('deletes expired authorization codes and keeps live ones', async () => { + codeRows.push( + { id: 'expired-code', expiresAt: isoDaysAgo(1) }, + { id: 'live-code', expiresAt: isoInMinutes(1) }, + ); + + await oauthCleanupJobHandler(); + + expect(codeRows.map((row) => row.id)).toEqual(['live-code']); + }); + + it('deletes expired pending authorizations and keeps live ones', async () => { + pendingRows.push( + { id: 'expired-pending', expiresAt: isoDaysAgo(1) }, + { id: 'live-pending', expiresAt: isoInMinutes(10) }, + ); + + await oauthCleanupJobHandler(); + + expect(pendingRows.map((row) => row.id)).toEqual(['live-pending']); + }); + + it('deletes refresh tokens that can no longer be rotated', async () => { + refreshRows.push( + { id: 'expired-token', expiresAt: isoDaysAgo(1), revokedAt: null }, + { id: 'live-token', expiresAt: isoDaysAgo(-30), revokedAt: null }, + ); + + await oauthCleanupJobHandler(); + + expect(refreshRows.map((row) => row.id)).toEqual(['live-token']); + }); + + it('keeps revoked refresh tokens until they expire, however long ago they were rotated', async () => { + refreshRows.push( + { + id: 'revoked-long-ago-still-valid', + expiresAt: isoDaysAgo(-20), + revokedAt: isoDaysAgo(25), + }, + { + id: 'revoked-recently', + expiresAt: isoDaysAgo(-20), + revokedAt: isoDaysAgo(1), + }, + { + id: 'revoked-and-expired', + expiresAt: isoDaysAgo(1), + revokedAt: isoDaysAgo(25), + }, + { + id: 'never-revoked', + expiresAt: isoDaysAgo(-20), + revokedAt: null, + }, + ); + + await oauthCleanupJobHandler(); + + // A row survives while its token could still be presented, which is the window in + // which a replay must be recognised as reuse rather than an unknown token. + expect(refreshRows.map((row) => row.id)).toEqual([ + 'revoked-long-ago-still-valid', + 'revoked-recently', + 'never-revoked', + ]); + }); + + it('deletes old public clients that no grant references, via a NOT EXISTS subquery', async () => { + await oauthCleanupJobHandler(); + + expect(clientQueryBuilder.execute).toHaveBeenCalledTimes(1); + + const whereClauses = [ + ...clientQueryBuilder.where.mock.calls, + ...clientQueryBuilder.andWhere.mock.calls, + ]; + const clauseSql = whereClauses.map((call) => call[0] as string).join(' | '); + + expect(clauseSql).toContain('"created" <'); + expect(clauseSql).toContain('"tokenEndpointAuthMethod" ='); + expect(clauseSql).toContain('NOT EXISTS'); + expect(clauseSql).toContain('oauth_grant'); + + const parameters = Object.assign( + {}, + ...whereClauses.map((call) => call[1] ?? {}), + ) as Record; + + expect(parameters.authMethod).toBe('none'); + // Bound as a Date, so the driver serialises it the way it serialises stored values. + expect(parameters.cutoff).toBeInstanceOf(Date); + const cutoffAge = Date.now() - (parameters.cutoff as Date).getTime(); + expect(cutoffAge).toBeGreaterThan(29 * DAY_MS); + expect(cutoffAge).toBeLessThan(31 * DAY_MS); + }); + + it('logs a single summary with the deleted counts', async () => { + codeRows.push({ id: 'expired-code', expiresAt: isoDaysAgo(1) }); + pendingRows.push({ id: 'expired-pending', expiresAt: isoDaysAgo(1) }); + refreshRows.push( + { id: 'expired-token', expiresAt: isoDaysAgo(1), revokedAt: null }, + { + id: 'revoked-long-ago', + expiresAt: isoDaysAgo(-30), + revokedAt: isoDaysAgo(8), + }, + ); + + await oauthCleanupJobHandler(); + + expect(loggerInfo).toHaveBeenCalledTimes(1); + expect(loggerInfo.mock.calls[0][1]).toEqual({ + authorizationCodes: 1, + pendingAuthorizations: 1, + expiredRefreshTokens: 1, + unusedClients: 2, + deadGrants: 1, + }); + }); + + it('exposes value on a LessThan find operator, which the store mock relies on', () => { + expect(LessThan('2020-01-01').value).toBe('2020-01-01'); + }); +}); diff --git a/packages/server/api/test/unit/oauth/oauth-cleanup-registration.test.ts b/packages/server/api/test/unit/oauth/oauth-cleanup-registration.test.ts new file mode 100644 index 0000000000..3ca3803a7c --- /dev/null +++ b/packages/server/api/test/unit/oauth/oauth-cleanup-registration.test.ts @@ -0,0 +1,96 @@ +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 { oauthConfig } from '../../../src/app/oauth/config/oauth-config'; +import { + registerOAuthCleanupHandler, + scheduleOAuthCleanupJob, +} from '../../../src/app/oauth/oauth-cleanup-job'; + +// 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 finds no 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 cannot be what registers + // the handler. + expect(registerJobHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/api/test/unit/oauth/projects/available-projects.test.ts b/packages/server/api/test/unit/oauth/projects/available-projects.test.ts new file mode 100644 index 0000000000..23045748b2 --- /dev/null +++ b/packages/server/api/test/unit/oauth/projects/available-projects.test.ts @@ -0,0 +1,92 @@ +const userGetMock = jest.fn(); +const projectGetManyByIdsMock = jest.fn(); +const listForUserMock = jest.fn(); + +jest.mock('../../../../src/app/user/user-service', () => ({ + userService: { get: userGetMock }, +})); + +jest.mock('../../../../src/app/project/project-service', () => ({ + projectService: { getManyByIds: projectGetManyByIdsMock }, +})); + +jest.mock( + '../../../../src/app/oauth/projects/project-membership-factory', + () => ({ + getOAuthProjectMembershipService: () => ({ + listForUser: listForUserMock, + }), + }), +); + +import { listAvailableProjects } from '../../../../src/app/oauth/projects/available-projects'; + +const USER = { id: 'user-1', organizationId: 'org-1' }; + +describe('listAvailableProjects', () => { + beforeEach(() => { + jest.clearAllMocks(); + userGetMock.mockResolvedValue(USER); + listForUserMock.mockResolvedValue([ + { projectId: 'proj-1', organizationId: 'org-1', projectRole: 'ADMIN' }, + { projectId: 'proj-2', organizationId: 'org-1', projectRole: 'ADMIN' }, + ]); + projectGetManyByIdsMock.mockResolvedValue([ + { id: 'proj-1', displayName: 'Cloud Ops' }, + { id: 'proj-2', displayName: '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: any other source 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 () => { + projectGetManyByIdsMock.mockResolvedValue([]); + + // 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' }, + ]); + }); + + it('reads every project in one query', async () => { + await listAvailableProjects('user-1'); + + // An agent polls this endpoint, so a query per membership would scale with the number + // of projects in the organization. + expect(projectGetManyByIdsMock).toHaveBeenCalledTimes(1); + expect(projectGetManyByIdsMock).toHaveBeenCalledWith(['proj-1', 'proj-2']); + }); + + it('reports names in membership order, whatever order the rows come back in', async () => { + projectGetManyByIdsMock.mockResolvedValue([ + { id: 'proj-2', displayName: 'Data' }, + { id: 'proj-1', displayName: 'Cloud Ops' }, + ]); + + await expect(listAvailableProjects('user-1')).resolves.toEqual([ + { projectId: 'proj-1', projectName: 'Cloud Ops' }, + { projectId: 'proj-2', projectName: 'Data' }, + ]); + }); +}); diff --git a/packages/server/api/test/unit/oauth/projects/oauth-principal.test.ts b/packages/server/api/test/unit/oauth/projects/oauth-principal.test.ts new file mode 100644 index 0000000000..458002aa15 --- /dev/null +++ b/packages/server/api/test/unit/oauth/projects/oauth-principal.test.ts @@ -0,0 +1,381 @@ +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/clients/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/projects/project-membership-factory', + () => ({ + getOAuthProjectMembershipService: () => membershipService, + }), +); + +import { accessTokenManager } from '../../../../src/app/authentication/context/access-token-manager'; +import { grantsService } from '../../../../src/app/oauth/clients/grants.service'; +import { + invalidGrant, + serverError, +} from '../../../../src/app/oauth/common/oauth-errors'; +import { oauthConfig } from '../../../../src/app/oauth/config/oauth-config'; +import { signingKeyService } from '../../../../src/app/oauth/tokens/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); + + // Stands in for the real key store, enforcing the audience as production 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) { + // An OAuthError, as production reports: the caller distinguishes those from + // server faults. + throw invalidGrant((error as Error).message); + } + }); + + (grantsService.getActiveGrantOrThrow as jest.Mock).mockResolvedValue( + activeGrant, + ); + (userService.get as jest.Mock).mockResolvedValue(activeUser); + membershipService.getForUser.mockResolvedValue(MEMBERSHIP); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + /** + * SERVICE, never USER. Routes restricted to `PrincipalType.USER` act on the session + * rather than within a project — enterprise's `/switch-project` mints a token for a + * different one and is exempt from the guard on naming another project. The principal + * type is therefore the only thing keeping a connection inside the project its token + * names. Do not widen this to USER. + */ + it('builds a SERVICE principal on the grant active project', async () => { + const principal = await accessTokenManager.extractPrincipal( + signOAuthToken(), + ); + + expect(principal).toEqual({ + id: 'user-1', + externalId: 'ext-1', + type: PrincipalType.SERVICE, + projectId: 'project-1', + projectRole: 'ADMIN', + organization: { id: 'org-1', role: 'ADMIN' }, + }); + }); + + it('acts on the project named by the token, not the one on the grant', async () => { + // The grant records what was authorized; the token decides what this credential does. + (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', () => { + // A 401 has clients discard their refresh token and re-authorize, so a database blip + // must not look like one. + it.each([ + ['the grant lookup', () => grantsService.getActiveGrantOrThrow], + ['the user lookup', () => userService.get], + ['the membership lookup', () => membershipService.getForUser], + ])( + 'propagates a failure in %s instead of returning 401', + async (_l, get) => { + (get() as jest.Mock).mockRejectedValue( + new Error('connection terminated unexpectedly'), + ); + + await expect( + accessTokenManager.extractPrincipal(signOAuthToken()), + ).rejects.toThrow('connection terminated unexpectedly'); + }, + ); + + it('propagates a signing-key store failure instead of returning 401', async () => { + (signingKeyService.verifyAccessToken as jest.Mock).mockRejectedValue( + serverError('OAuth signing key is not initialized'), + ); + + await expect( + accessTokenManager.extractPrincipal(signOAuthToken()), + ).rejects.toThrow('signing key is not initialized'); + }); + }); + + it('rejects OAuth tokens entirely when the feature is disabled', async () => { + jest.spyOn(oauthConfig, 'isEnabled').mockReturnValue(false); + + await expect( + accessTokenManager.extractPrincipal(signOAuthToken()), + ).rejects.toThrow('INVALID_BEARER_TOKEN'); + expect(grantsService.getActiveGrantOrThrow).not.toHaveBeenCalled(); + }); + + it('still accepts internal HS256 tokens, which never reach the OAuth path', async () => { + const internalToken = await accessTokenManager.generateToken({ + id: 'user-9', + type: PrincipalType.USER, + projectId: 'project-9', + projectRole: 'ADMIN', + organization: { id: 'org-9', role: 'ADMIN' }, + } as never); + + const principal = await accessTokenManager.extractPrincipal(internalToken); + + expect(principal).toMatchObject({ + id: 'user-9', + type: PrincipalType.USER, + projectId: 'project-9', + }); + expect(signingKeyService.verifyAccessToken).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/api/test/unit/oauth/projects/project-membership.test.ts b/packages/server/api/test/unit/oauth/projects/project-membership.test.ts new file mode 100644 index 0000000000..bfeb8e504a --- /dev/null +++ b/packages/server/api/test/unit/oauth/projects/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/projects/project-membership'; +import { getOAuthProjectMembershipService } from '../../../../src/app/oauth/projects/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/tokens/signing-key.service.test.ts b/packages/server/api/test/unit/oauth/tokens/signing-key.service.test.ts new file mode 100644 index 0000000000..b69aa323a5 --- /dev/null +++ b/packages/server/api/test/unit/oauth/tokens/signing-key.service.test.ts @@ -0,0 +1,388 @@ +import jwt from 'jsonwebtoken'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +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 both "no plaintext PEM is stored" and +// "the service decrypts before signing" are testable without a real encryption key. +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/config/oauth-config'; +import { signingKeyService } from '../../../../src/app/oauth/tokens/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, + ); + + 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', + ); + }); + + // An operator-supplied key is not read again until the first sign or verify, so anything + // wrong with it has to be caught at boot or it surfaces as a 500 on a token request. + describe('operator-supplied signing key', () => { + let dir: string; + + const write = (name: string, contents: string): string => { + const file = path.join(dir, name); + fs.writeFileSync(file, contents, 'utf-8'); + return file; + }; + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oauth-signing-key-')); + }); + + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('accepts an RSA private key and generates no key of its own', async () => { + const { privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + }); + const pemPath = write( + 'valid.pem', + privateKey.export({ type: 'pkcs8', format: 'pem' }) as string, + ); + jest.spyOn(oauthConfig, 'getSigningKeyPemPath').mockReturnValue(pemPath); + + await expect( + signingKeyService.ensureSigningKey(), + ).resolves.toBeUndefined(); + expect(keyRows).toHaveLength(0); + }); + + it('refuses to boot when the file is missing', async () => { + jest + .spyOn(oauthConfig, 'getSigningKeyPemPath') + .mockReturnValue(path.join(dir, 'absent.pem')); + + await expect(signingKeyService.ensureSigningKey()).rejects.toThrow( + 'OPS_OAUTH_SIGNING_KEY_PEM_PATH must point at a readable PEM private key', + ); + }); + + it('refuses to boot when pointed at a public key', async () => { + const { publicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + }); + const pemPath = write( + 'public.pem', + publicKey.export({ type: 'spki', format: 'pem' }) as string, + ); + jest.spyOn(oauthConfig, 'getSigningKeyPemPath').mockReturnValue(pemPath); + + // The likely mistake: naming the `.pub` half. A public key parses as a key, so only + // an explicit private-key check rejects it. + await expect(signingKeyService.ensureSigningKey()).rejects.toThrow( + 'must point at a readable PEM private key', + ); + }); + + it('refuses to boot on a key type RS256 cannot sign with', async () => { + const { privateKey } = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + const pemPath = write( + 'ec.pem', + privateKey.export({ type: 'pkcs8', format: 'pem' }) as string, + ); + jest.spyOn(oauthConfig, 'getSigningKeyPemPath').mockReturnValue(pemPath); + + await expect(signingKeyService.ensureSigningKey()).rejects.toThrow( + 'must be an RSA private key to sign RS256 tokens, got ec', + ); + }); + }); +}); diff --git a/packages/server/api/test/unit/oauth/tokens/token-exchange.test.ts b/packages/server/api/test/unit/oauth/tokens/token-exchange.test.ts new file mode 100644 index 0000000000..f34df523bf --- /dev/null +++ b/packages/server/api/test/unit/oauth/tokens/token-exchange.test.ts @@ -0,0 +1,410 @@ +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/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/clients/grants.service', () => ({ + grantsService: { + getActiveGrantOrThrow: jest.fn(), + touch: jest.fn(), + }, +})); + +jest.mock('../../../../src/app/oauth/tokens/tokens.service', () => ({ + tokensService: { + mintExchangedApiToken: jest.fn(), + }, +})); + +jest.mock('../../../../src/app/oauth/tokens/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/projects/project-membership-factory', + () => ({ + getOAuthProjectMembershipService: () => membershipService, + }), +); + +import { + clientsService, + TOKEN_EXCHANGE_GRANT, +} from '../../../../src/app/oauth/clients/clients.service'; +import { grantsService } from '../../../../src/app/oauth/clients/grants.service'; +import { OAuthError } from '../../../../src/app/oauth/common/oauth-errors'; +import { oauthConfig } from '../../../../src/app/oauth/config/oauth-config'; +import { signingKeyService } from '../../../../src/app/oauth/tokens/signing-key.service'; +import { + exchangeToken, + ExchangeTokenParams, +} from '../../../../src/app/oauth/tokens/token-exchange'; +import { tokensService } from '../../../../src/app/oauth/tokens/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 () => { + // The exchanged token refers to the subject token's project, not the grant's. + verifyAccessTokenMock.mockResolvedValue({ + sub: 'user-1', + aud: MCP_URI, + client_id: 'client-1', + scope: 'mcp', + grant_id: 'grant-1', + project_id: 'project-2', + }); + getForUserMock.mockResolvedValue({ + projectId: 'project-2', + organizationId: 'org-1', + projectRole: 'ADMIN', + }); + + await exchangeToken(exchangeParams()); + + expect(getForUserMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'user-1' }), + 'project-2', + ); + expect(mintMock).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'project-2' }), + ); + }); + + it('acts in a requested project instead of the subject token one', async () => { + // How an agent switches project: it names where it wants to act, and this decides. + 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); + + // 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' })); + + // Checking the wrong project would authorize a switch on access to the project being + // switched away from. + expect(getForUserMock).not.toHaveBeenCalledWith( + expect.anything(), + 'project-1', + ); + }); + + it('rejects a subject token that names no project', async () => { + verifyAccessTokenMock.mockResolvedValue({ + sub: 'user-1', + aud: MCP_URI, + client_id: 'client-1', + scope: 'mcp', + grant_id: 'grant-1', + }); + + await expect(exchangeToken(exchangeParams())).rejects.toMatchObject({ + errorCode: 'invalid_grant', + }); + expect(mintMock).not.toHaveBeenCalled(); + }); + + it('rejects when no mcp resource is configured', async () => { + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(undefined); + + await expect(exchangeToken(exchangeParams())).rejects.toMatchObject({ + errorCode: 'invalid_target', + description: 'the mcp resource is not configured', + }); + expect(verifyAccessTokenMock).not.toHaveBeenCalled(); + expect(mintMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/api/test/unit/oauth/tokens/tokens.service.test.ts b/packages/server/api/test/unit/oauth/tokens/tokens.service.test.ts new file mode 100644 index 0000000000..b29f55324f --- /dev/null +++ b/packages/server/api/test/unit/oauth/tokens/tokens.service.test.ts @@ -0,0 +1,746 @@ +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/clients/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/projects/project-membership-factory', + () => ({ + getOAuthProjectMembershipService: () => membershipService, + }), +); + +import { grantsService } from '../../../../src/app/oauth/clients/grants.service'; +import { sha256Hex } from '../../../../src/app/oauth/common/oauth-crypto'; +import { oauthConfig } from '../../../../src/app/oauth/config/oauth-config'; +import { OAuthPendingAuthorization } from '../../../../src/app/oauth/storage/oauth-model'; +import { signingKeyService } from '../../../../src/app/oauth/tokens/signing-key.service'; +import { tokensService } from '../../../../src/app/oauth/tokens/tokens.service'; +import { userService } from '../../../../src/app/user/user-service'; + +const PENDING: OAuthPendingAuthorization = { + id: 'pending-1', + created: new Date().toISOString(), + updated: new Date().toISOString(), + clientId: 'client-1', + redirectUri: 'https://client.example/cb', + codeChallenge: CODE_CHALLENGE, + resource: MCP_URI, + scope: 'mcp', + state: 'state-1', + expiresAt: new Date(Date.now() + 600_000).toISOString(), + consumedAt: null, +}; + +function redeemParams(overrides: Partial> = {}) { + return { + code: 'unset', + clientId: 'client-1', + redirectUri: 'https://client.example/cb', + codeVerifier: CODE_VERIFIER, + resource: MCP_URI, + ...overrides, + } as Parameters[0]; +} + +describe('tokensService', () => { + beforeEach(() => { + codeRows.length = 0; + refreshRows.length = 0; + jest.clearAllMocks(); + + jest.spyOn(oauthConfig, 'getApiAudience').mockReturnValue(API_URI); + jest.spyOn(oauthConfig, 'getMcpResourceUrl').mockReturnValue(MCP_URI); + jest.spyOn(oauthConfig, 'getAccessTokenTtlSeconds').mockReturnValue(900); + jest.spyOn(oauthConfig, 'getRefreshTokenTtlDays').mockReturnValue(30); + jest.spyOn(oauthConfig, 'getExchangeTokenTtlSeconds').mockReturnValue(300); + jest + .spyOn(signingKeyService, 'signAccessToken') + .mockImplementation(async (claims, ttl) => + JSON.stringify({ ...claims, ttl }), + ); + (grantsService.create as jest.Mock).mockResolvedValue(mockGrant); + (grantsService.getActiveGrantOrThrow as jest.Mock).mockResolvedValue( + mockGrant, + ); + (grantsService.getGrantSnapshot as jest.Mock).mockResolvedValue(mockGrant); + (userService.get as jest.Mock).mockResolvedValue({ + id: 'user-1', + status: 'ACTIVE', + organizationId: 'org-1', + }); + membershipService.getDefaultForUser.mockResolvedValue(MEMBERSHIP); + // Echoes the project it is asked about: a fixed membership would make every caller + // look correct no matter which project it passed. + membershipService.getForUser.mockImplementation( + async (_user: unknown, projectId: unknown) => ({ + ...MEMBERSHIP, + projectId: projectId as string, + }), + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('issueAuthorizationCode', () => { + it('stores only a hash of the code and copies the validated parameters', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + + expect(codeRows).toHaveLength(1); + expect(codeRows[0].codeHash).toBe(sha256Hex(code)); + expect(Object.values(codeRows[0])).not.toContain(code); + expect(codeRows[0]).toMatchObject({ + clientId: 'client-1', + userId: 'user-1', + redirectUri: 'https://client.example/cb', + codeChallenge: CODE_CHALLENGE, + resource: MCP_URI, + scope: 'mcp', + consumedAt: null, + }); + }); + + it('expires the code within a minute', async () => { + await tokensService.issueAuthorizationCode(PENDING, 'user-1'); + + const expiresAt = new Date(codeRows[0].expiresAt as string).getTime(); + expect(expiresAt - Date.now()).toBeLessThanOrEqual(60_000); + expect(expiresAt - Date.now()).toBeGreaterThan(50_000); + }); + }); + + describe('redeemAuthorizationCode', () => { + it('returns an access token and refresh token for a valid redemption', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + + const response = await tokensService.redeemAuthorizationCode( + redeemParams({ code }), + ); + + expect(response).toMatchObject({ + token_type: 'Bearer', + expires_in: 900, + scope: 'mcp', + }); + expect(response.refresh_token).toEqual(expect.any(String)); + expect(JSON.parse(response.access_token)).toMatchObject({ + sub: 'user-1', + aud: MCP_URI, + client_id: 'client-1', + scope: 'mcp', + grant_id: 'grant-1', + }); + }); + + it('records the project on the refresh token, not the grant', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + + await tokensService.redeemAuthorizationCode(redeemParams({ code })); + + // The chain carries it forward, so a plain renewal stays where the connection is. + expect(refreshRows[0].projectId).toBe('project-1'); + }); + + it('pins the project into the token claims', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + + const response = await tokensService.redeemAuthorizationCode( + redeemParams({ code }), + ); + + expect(JSON.parse(response.access_token).project_id).toBe('project-1'); + }); + + it('binds the access token to the mcp audience, never the api audience', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + + const response = await tokensService.redeemAuthorizationCode( + redeemParams({ code }), + ); + + expect(JSON.parse(response.access_token).aud).not.toBe(API_URI); + }); + + it('stores the refresh token hashed, with a fresh family', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + + const response = await tokensService.redeemAuthorizationCode( + redeemParams({ code }), + ); + + expect(refreshRows).toHaveLength(1); + expect(refreshRows[0].tokenHash).toBe( + sha256Hex(response.refresh_token as string), + ); + expect(Object.values(refreshRows[0])).not.toContain( + response.refresh_token, + ); + expect(refreshRows[0].familyId).toEqual(expect.any(String)); + expect(refreshRows[0].grantId).toBe('grant-1'); + }); + + it('activates the grant only on redemption', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + expect(grantsService.create).not.toHaveBeenCalled(); + + await tokensService.redeemAuthorizationCode(redeemParams({ code })); + + expect(grantsService.create).toHaveBeenCalledWith({ + clientId: 'client-1', + userId: 'user-1', + resourceId: 'mcp', + }); + }); + + it('rejects a replayed code and issues no second token', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + await tokensService.redeemAuthorizationCode(redeemParams({ code })); + + await expect( + tokensService.redeemAuthorizationCode(redeemParams({ code })), + ).rejects.toThrow('invalid or expired authorization code'); + expect(refreshRows).toHaveLength(1); + }); + + it('lets exactly one of two concurrent redemptions succeed', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + + const results = await Promise.allSettled([ + tokensService.redeemAuthorizationCode(redeemParams({ code })), + tokensService.redeemAuthorizationCode(redeemParams({ code })), + ]); + + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + expect(results.filter((r) => r.status === 'rejected')).toHaveLength(1); + expect(refreshRows).toHaveLength(1); + }); + + it('rejects an unknown code', async () => { + await expect( + tokensService.redeemAuthorizationCode(redeemParams({ code: 'nope' })), + ).rejects.toThrow('invalid or expired authorization code'); + }); + + it('rejects an expired code', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + codeRows[0].expiresAt = new Date(Date.now() - 1000).toISOString(); + + await expect( + tokensService.redeemAuthorizationCode(redeemParams({ code })), + ).rejects.toThrow('invalid or expired authorization code'); + expect(refreshRows).toHaveLength(0); + }); + + it.each([ + ['a different client', { clientId: 'other-client' }], + ['a different redirect uri', { redirectUri: 'https://evil.example/cb' }], + ['a different resource', { resource: API_URI }], + ['an unknown resource', { resource: 'https://elsewhere.example' }], + ['a wrong pkce verifier', { codeVerifier: 'x'.repeat(43) }], + ])('rejects redemption with %s', async (_label, overrides) => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + + await expect( + tokensService.redeemAuthorizationCode( + redeemParams({ code, ...overrides }), + ), + ).rejects.toThrow('invalid or expired authorization code'); + expect(refreshRows).toHaveLength(0); + }); + + it('rejects redemption for a deactivated user', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + (userService.get as jest.Mock).mockResolvedValue({ + id: 'user-1', + status: 'INACTIVE', + }); + + await expect( + tokensService.redeemAuthorizationCode(redeemParams({ code })), + ).rejects.toThrow('no longer active'); + expect(refreshRows).toHaveLength(0); + }); + }); + + describe('rotateRefreshToken', () => { + async function issueInitialTokens(): Promise { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + const response = await tokensService.redeemAuthorizationCode( + redeemParams({ code }), + ); + return response.refresh_token as string; + } + + it('issues a new pair and revokes the presented token', async () => { + const original = await issueInitialTokens(); + + const rotated = await tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }); + + expect(rotated.refresh_token).not.toBe(original); + expect(refreshRows).toHaveLength(2); + expect(refreshRows[0].revokedAt).toEqual(expect.any(String)); + expect(refreshRows[1].revokedAt).toBeNull(); + }); + + it('keeps the rotated token in the same family', async () => { + const original = await issueInitialTokens(); + + await tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }); + + expect(refreshRows[1].familyId).toBe(refreshRows[0].familyId); + }); + + it('revokes the entire family when a rotated token is presented again', async () => { + const original = await issueInitialTokens(); + const rotated = await tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).rejects.toThrow('refresh token reuse detected'); + + // The whole chain is untrusted once a replay is observed, the real client's token + // included. + await expect( + tokensService.rotateRefreshToken({ + refreshToken: rotated.refresh_token as string, + clientId: 'client-1', + }), + ).rejects.toThrow('refresh token reuse detected'); + expect(refreshRows.every((row) => row.revokedAt !== null)).toBe(true); + }); + + it('reports a revoked connection as revoked, not as a replay', async () => { + const original = await issueInitialTokens(); + // Revoking a grant also revokes its tokens, so the claim fails for a reason that is + // not an attack. + refreshRows[0].revokedAt = new Date().toISOString(); + (grantsService.getGrantSnapshot as jest.Mock).mockResolvedValue({ + ...mockGrant, + status: 'revoked', + }); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).rejects.toThrow('has been revoked'); + }); + + it('refuses to refresh once the user loses access to the project', async () => { + const original = await issueInitialTokens(); + membershipService.getForUser.mockResolvedValue(null); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).rejects.toThrow('not accessible'); + }); + + it('re-authorizes the project on every rotation', async () => { + const original = await issueInitialTokens(); + membershipService.getForUser.mockClear(); + + await tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }); + + expect(membershipService.getForUser).toHaveBeenCalledWith( + expect.objectContaining({ id: 'user-1' }), + 'project-1', + ); + }); + + it('switches the connection to a requested project', async () => { + const original = await issueInitialTokens(); + membershipService.getForUser.mockResolvedValue({ + projectId: 'project-2', + organizationId: 'org-1', + projectRole: 'ADMIN', + }); + + const rotated = await tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + requestedProjectId: 'project-2', + }); + + expect(membershipService.getForUser).toHaveBeenCalledWith( + expect.objectContaining({ id: 'user-1' }), + 'project-2', + ); + expect(JSON.parse(rotated.access_token).project_id).toBe('project-2'); + }); + + it('refuses a requested project the user is not a member of', async () => { + const original = await issueInitialTokens(); + membershipService.getForUser.mockResolvedValue(null); + + // invalid_target, not invalid_grant: the client asked for something it may not have, + // which it can correct. + 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 would brick a working agent, and its retry would look like a replay. + membershipService.getForUser.mockResolvedValue(MEMBERSHIP); + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).resolves.toEqual( + expect.objectContaining({ access_token: expect.any(String) }), + ); + }); + + it('keeps a switched project across a later plain refresh', async () => { + const original = await issueInitialTokens(); + + const switched = await tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + requestedProjectId: 'project-2', + }); + + const renewed = await tokensService.rotateRefreshToken({ + refreshToken: switched.refresh_token as string, + clientId: 'client-1', + }); + + // Must not fall back to where the connection started: renewing hands back an + // equivalent credential, not one pointing somewhere else. + expect(JSON.parse(renewed.access_token).project_id).toBe('project-2'); + }); + + it('stays where it is when no project is requested', async () => { + const original = await issueInitialTokens(); + membershipService.getForUser.mockClear(); + + const rotated = await tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }); + + expect(membershipService.getForUser).toHaveBeenCalledWith( + expect.anything(), + 'project-1', + ); + expect(JSON.parse(rotated.access_token).project_id).toBe('project-1'); + }); + + it('rejects an unknown refresh token', async () => { + await expect( + tokensService.rotateRefreshToken({ + refreshToken: 'nope', + clientId: 'client-1', + }), + ).rejects.toThrow('invalid refresh token'); + }); + + it('rejects rotation by a different client without destroying the token', async () => { + const original = await issueInitialTokens(); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'other-client', + }), + ).rejects.toThrow('invalid refresh token'); + + // A rejected request must leave the credential usable, or the client's next attempt + // would look like a replay and kill the connection. + expect(refreshRows[0].revokedAt).toBeNull(); + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).resolves.toMatchObject({ token_type: 'Bearer' }); + }); + + it('rejects an expired refresh token without consuming it', async () => { + const original = await issueInitialTokens(); + refreshRows[0].expiresAt = new Date(Date.now() - 1000).toISOString(); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).rejects.toThrow('expired'); + expect(refreshRows[0].revokedAt).toBeNull(); + }); + + it('does not revoke the family when the project is no longer accessible', async () => { + const original = await issueInitialTokens(); + membershipService.getForUser.mockResolvedValue(null); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).rejects.toThrow('not accessible'); + expect(refreshRows[0].revokedAt).toBeNull(); + }); + + it('refuses to refresh once the grant is revoked', async () => { + const original = await issueInitialTokens(); + (grantsService.getActiveGrantOrThrow as jest.Mock).mockRejectedValue( + new Error('the authorization for this client has been revoked'), + ); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).rejects.toThrow('revoked'); + }); + + it('survives a transient failure so the retry is not read as a replay', async () => { + const original = await issueInitialTokens(); + (userService.get as jest.Mock).mockRejectedValueOnce( + new Error('connection terminated unexpectedly'), + ); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).rejects.toThrow('connection terminated'); + + expect(refreshRows[0].revokedAt).toBeNull(); + + const retry = await tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }); + + expect(retry.refresh_token).toEqual(expect.any(String)); + expect(refreshRows.every((row) => row.revokedAt !== null)).toBe(false); + }); + + it('refuses to refresh for a deactivated user', async () => { + const original = await issueInitialTokens(); + (userService.get as jest.Mock).mockResolvedValue({ + id: 'user-1', + status: 'INACTIVE', + }); + + await expect( + tokensService.rotateRefreshToken({ + refreshToken: original, + clientId: 'client-1', + }), + ).rejects.toThrow('no longer active'); + }); + }); + + describe('revokeByRefreshToken', () => { + it('revokes the grant behind the token', async () => { + const code = await tokensService.issueAuthorizationCode( + PENDING, + 'user-1', + ); + const response = await tokensService.redeemAuthorizationCode( + redeemParams({ code }), + ); + + await tokensService.revokeByRefreshToken( + response.refresh_token as string, + ); + + expect(grantsService.revoke).toHaveBeenCalledWith('grant-1'); + }); + + it('ignores an unknown token, as RFC 7009 requires', async () => { + await expect( + tokensService.revokeByRefreshToken('unknown'), + ).resolves.toBeUndefined(); + expect(grantsService.revoke).not.toHaveBeenCalled(); + }); + }); + + describe('mintExchangedApiToken', () => { + it('mints a short-lived api-audience token for the grant', async () => { + const result = await tokensService.mintExchangedApiToken({ + grant: mockGrant, + scope: 'api', + projectId: 'project-7', + }); + + expect(result.expiresIn).toBe(300); + expect(JSON.parse(result.accessToken)).toMatchObject({ + sub: 'user-1', + aud: API_URI, + grant_id: 'grant-1', + scope: 'api', + project_id: 'project-7', + ttl: 300, + }); + }); + }); +}); diff --git a/packages/server/shared/src/lib/system/system-prop.ts b/packages/server/shared/src/lib/system/system-prop.ts index 0ccd4c8596..edbebb027c 100644 --- a/packages/server/shared/src/lib/system/system-prop.ts +++ b/packages/server/shared/src/lib/system/system-prop.ts @@ -51,6 +51,15 @@ export enum AppSystemProp { JWT_TOKEN_LIFETIME_HOURS = 'JWT_TOKEN_LIFETIME_HOURS', TABLES_TOKEN_LIFETIME_MINUTES = 'TABLES_TOKEN_LIFETIME_MINUTES', + OAUTH_ENABLED = 'OAUTH_ENABLED', + OAUTH_ISSUER_URL = 'OAUTH_ISSUER_URL', + OAUTH_ACCESS_TOKEN_TTL_SECONDS = 'OAUTH_ACCESS_TOKEN_TTL_SECONDS', + OAUTH_REFRESH_TOKEN_TTL_DAYS = 'OAUTH_REFRESH_TOKEN_TTL_DAYS', + OAUTH_EXCHANGE_TOKEN_TTL_SECONDS = 'OAUTH_EXCHANGE_TOKEN_TTL_SECONDS', + OAUTH_SIGNING_KEY_PEM_PATH = 'OAUTH_SIGNING_KEY_PEM_PATH', + OAUTH_RS_CLIENT_SECRET = 'OAUTH_RS_CLIENT_SECRET', + MCP_RESOURCE_URL = 'MCP_RESOURCE_URL', + // ENTERPRISE ONLY FIREBASE_ADMIN_CREDENTIALS = 'FIREBASE_ADMIN_CREDENTIALS', FIREBASE_HASH_PARAMETERS = 'FIREBASE_HASH_PARAMETERS', diff --git a/packages/server/shared/src/lib/system/system.ts b/packages/server/shared/src/lib/system/system.ts index 5441eccd4b..3c5fe2808f 100644 --- a/packages/server/shared/src/lib/system/system.ts +++ b/packages/server/shared/src/lib/system/system.ts @@ -81,6 +81,10 @@ const systemPropDefaultValues: Partial> = { [AppSystemProp.ANALYTICS_ENABLED]: 'true', [SharedSystemProp.EXECUTION_MODE]: 'SANDBOX_CODE_ONLY', [AppSystemProp.JWT_TOKEN_LIFETIME_HOURS]: '168', + [AppSystemProp.OAUTH_ENABLED]: 'false', + [AppSystemProp.OAUTH_ACCESS_TOKEN_TTL_SECONDS]: '900', + [AppSystemProp.OAUTH_REFRESH_TOKEN_TTL_DAYS]: '30', + [AppSystemProp.OAUTH_EXCHANGE_TOKEN_TTL_SECONDS]: '300', [AppSystemProp.DARK_THEME_ENABLED]: 'false', [AppSystemProp.SHOW_DEMO_HOME_PAGE]: 'false', [AppSystemProp.SEED_DEV_DATA]: 'false', diff --git a/packages/shared/src/lib/flag/flag.ts b/packages/shared/src/lib/flag/flag.ts index b7670480e9..1ef48be7be 100644 --- a/packages/shared/src/lib/flag/flag.ts +++ b/packages/shared/src/lib/flag/flag.ts @@ -62,4 +62,5 @@ export enum FlagId { FEDERATED_LOGIN_ENABLED = 'FEDERATED_LOGIN_ENABLED', FINOPS_BENCHMARK_ENABLED = 'FINOPS_BENCHMARK_ENABLED', ANALYTICS_DASHBOARDS = 'ANALYTICS_DASHBOARDS', + CONNECTED_APPS_ENABLED = 'CONNECTED_APPS_ENABLED', }