diff --git a/cdk/package.json b/cdk/package.json index be16744a..38dc196b 100644 --- a/cdk/package.json +++ b/cdk/package.json @@ -32,6 +32,7 @@ "@smithy/protocol-http": "^5.5.5", "@smithy/signature-v4": "^5.6.1", "aws-cdk-lib": "^2.260.0", + "aws-jwt-verify": "^5.2.1", "cdk-nag": "^2.38.2", "constructs": "^10.6.0", "js-yaml": "^4.1.1", diff --git a/cdk/src/constructs/api-key-table.ts b/cdk/src/constructs/api-key-table.ts new file mode 100644 index 00000000..09d0d51e --- /dev/null +++ b/cdk/src/constructs/api-key-table.ts @@ -0,0 +1,96 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** + * Properties for ApiKeyTable construct. + */ +export interface ApiKeyTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table for platform API keys (Cognito-free auth for headless clients). + * + * Schema: key_id (PK) with one GSI for querying by owner. + * + * The presented key is `bgak__`; the authorizer parses key_id + * and does a direct GetItem, so no secret-hash GSI is needed. Direct lookup by + * partition key also avoids GSI eventual-consistency, so a revoked key stops + * authenticating immediately. + * + * GSIs: + * - UserIndex (PK: user_id, SK: created_at) — list API keys for a user. + */ +export class ApiKeyTable extends Construct { + /** + * GSI name for querying API keys by owner. + * PK: user_id, SK: created_at. + */ + public static readonly USER_INDEX = 'UserIndex'; + + /** + * The underlying DynamoDB table. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: ApiKeyTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'key_id', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + + this.table.addGlobalSecondaryIndex({ + indexName: ApiKeyTable.USER_INDEX, + partitionKey: { name: 'user_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'created_at', type: dynamodb.AttributeType.STRING }, + projectionType: dynamodb.ProjectionType.ALL, + }); + } +} diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index d2f67993..d961f1c1 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -115,6 +115,20 @@ export interface TaskApiProps { */ readonly webhookTable?: dynamodb.ITable; + /** + * The DynamoDB platform API key table. When provided, API key management + * endpoints are created and the webhook management endpoints accept an API + * key (with `webhooks:manage`) in addition to a Cognito JWT. + */ + readonly apiKeyTable?: dynamodb.ITable; + + /** + * Number of days to retain revoked API key records before DynamoDB TTL + * deletes them. + * @default 30 + */ + readonly apiKeyRetentionDays?: number; + /** * ARN of the orchestrator Lambda alias. When set, the create-task handler * async-invokes the orchestrator after writing the task record. @@ -198,10 +212,13 @@ export interface TaskApiProps { * - GET /tasks/{task_id} → getTask (Cognito) * - DELETE /tasks/{task_id} → cancelTask (Cognito) * - GET /tasks/{task_id}/events → getTaskEvents (Cognito) - * - POST /webhooks → createWebhook (Cognito) - * - GET /webhooks → listWebhooks (Cognito) - * - DELETE /webhooks/{webhook_id} → deleteWebhook (Cognito) + * - POST /webhooks → createWebhook (Cognito, or JWT/API-key when apiKeyTable set) + * - GET /webhooks → listWebhooks (Cognito, or JWT/API-key when apiKeyTable set) + * - DELETE /webhooks/{webhook_id} → deleteWebhook (Cognito, or JWT/API-key when apiKeyTable set) * - POST /webhooks/tasks → webhookCreateTask (REQUEST authorizer) + * - POST /api-keys → createApiKey (Cognito) + * - GET /api-keys → listApiKeys (Cognito) + * - DELETE /api-keys/{key_id} → deleteApiKey (Cognito) */ export class TaskApi extends Construct { /** @@ -984,6 +1001,95 @@ export class TaskApi extends Construct { } } + // --- Platform API key infrastructure (only when apiKeyTable is provided) --- + // + // Default: webhook management routes stay Cognito-only. When an API key + // table is wired, a unified REQUEST authorizer replaces Cognito on those + // routes so they accept EITHER a Cognito JWT OR a `webhooks:manage` API key + // (issue #376). Key *creation* stays Cognito-gated. + let webhookMgmtAuthOptions: apigw.MethodOptions = cognitoAuthOptions; + + if (props.apiKeyTable) { + const apiKeyEnv: Record = { + API_KEY_TABLE_NAME: props.apiKeyTable.tableName, + API_KEY_RETENTION_DAYS: String(props.apiKeyRetentionDays ?? DEFAULT_WEBHOOK_RETENTION_DAYS), + }; + + // --- Unified authorizer: Cognito JWT OR platform API key --- + const apiKeyAuthorizerFn = new lambda.NodejsFunction(this, 'ApiKeyAuthorizerFn', { + entry: path.join(handlersDir, 'api-key-authorizer.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + environment: { + API_KEY_TABLE_NAME: props.apiKeyTable.tableName, + API_KEY_REQUIRED_SCOPE: 'webhooks:manage', + USER_POOL_ID: this.userPool.userPoolId, + APP_CLIENT_ID: this.appClient.userPoolClientId, + }, + bundling: commonBundling, + }); + props.apiKeyTable.grantReadData(apiKeyAuthorizerFn); + + // No fixed identity source: a request carries EITHER Authorization OR + // X-API-Key, never a guaranteed one. Multiple identity sources are AND-ed + // and would short-circuit to 401 before the Lambda runs, so we leave them + // empty and disable caching (the Lambda decides on every request). + const webhookMgmtAuthorizer = new apigw.RequestAuthorizer(this, 'ApiKeyOrJwtAuthorizer', { + handler: apiKeyAuthorizerFn, + identitySources: [], + resultsCacheTtl: Duration.seconds(0), + }); + + webhookMgmtAuthOptions = { + authorizer: webhookMgmtAuthorizer, + authorizationType: apigw.AuthorizationType.CUSTOM, + requestValidator, + }; + + // --- API key management Lambdas (Cognito-authenticated — minting a key + // requires a real interactive user) --- + const createApiKeyFn = new lambda.NodejsFunction(this, 'CreateApiKeyFn', { + entry: path.join(handlersDir, 'create-api-key.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + environment: apiKeyEnv, + bundling: commonBundling, + }); + + const listApiKeysFn = new lambda.NodejsFunction(this, 'ListApiKeysFn', { + entry: path.join(handlersDir, 'list-api-keys.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + environment: apiKeyEnv, + bundling: commonBundling, + }); + + const deleteApiKeyFn = new lambda.NodejsFunction(this, 'DeleteApiKeyFn', { + entry: path.join(handlersDir, 'delete-api-key.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + environment: apiKeyEnv, + bundling: commonBundling, + }); + + props.apiKeyTable.grantReadWriteData(createApiKeyFn); + props.apiKeyTable.grantReadData(listApiKeysFn); + props.apiKeyTable.grantReadWriteData(deleteApiKeyFn); + + const apiKeys = this.api.root.addResource('api-keys'); + apiKeys.addMethod('POST', new apigw.LambdaIntegration(createApiKeyFn), cognitoAuthOptions); + apiKeys.addMethod('GET', new apigw.LambdaIntegration(listApiKeysFn), cognitoAuthOptions); + + const apiKeyById = apiKeys.addResource('{key_id}'); + apiKeyById.addMethod('DELETE', new apigw.LambdaIntegration(deleteApiKeyFn), cognitoAuthOptions); + + allFunctions.push(apiKeyAuthorizerFn, createApiKeyFn, listApiKeysFn, deleteApiKeyFn); + } + // --- Webhook endpoints (only when webhookTable is provided) --- if (props.webhookTable) { const webhookEnv: Record = { @@ -1122,12 +1228,26 @@ export class TaskApi extends Construct { }; // --- API resource tree: /webhooks --- + // Management routes use the unified authorizer when an API key table is + // wired (JWT or `webhooks:manage` key), else fall back to Cognito-only. const webhooks = this.api.root.addResource('webhooks'); - webhooks.addMethod('POST', new apigw.LambdaIntegration(createWebhookFn), cognitoAuthOptions); - webhooks.addMethod('GET', new apigw.LambdaIntegration(listWebhooksFn), cognitoAuthOptions); + const createWebhookMethod = webhooks.addMethod('POST', new apigw.LambdaIntegration(createWebhookFn), webhookMgmtAuthOptions); + const listWebhooksMethod = webhooks.addMethod('GET', new apigw.LambdaIntegration(listWebhooksFn), webhookMgmtAuthOptions); const webhookById = webhooks.addResource('{webhook_id}'); - webhookById.addMethod('DELETE', new apigw.LambdaIntegration(deleteWebhookFn), cognitoAuthOptions); + const deleteWebhookMethod = webhookById.addMethod('DELETE', new apigw.LambdaIntegration(deleteWebhookFn), webhookMgmtAuthOptions); + + // When the unified authorizer is in use these methods are CUSTOM, not + // Cognito — suppress COG4 (the authorizer still enforces a Cognito JWT + // or a scoped API key; issue #376). + if (props.apiKeyTable) { + NagSuppressions.addResourceSuppressions([createWebhookMethod, listWebhooksMethod, deleteWebhookMethod], [ + { + id: 'AwsSolutions-COG4', + reason: 'Webhook management uses a unified REQUEST authorizer accepting a Cognito JWT or a scoped platform API key — by design for headless automation (issue #376)', + }, + ]); + } const webhookTasks = webhooks.addResource('tasks'); const webhookTasksMethod = webhookTasks.addMethod('POST', new apigw.LambdaIntegration(webhookCreateTaskFn), webhookAuthOptions); diff --git a/cdk/src/handlers/api-key-authorizer.ts b/cdk/src/handlers/api-key-authorizer.ts new file mode 100644 index 00000000..26a2ab88 --- /dev/null +++ b/cdk/src/handlers/api-key-authorizer.ts @@ -0,0 +1,165 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; +import { CognitoJwtVerifier } from 'aws-jwt-verify'; +import type { APIGatewayRequestAuthorizerEvent, APIGatewayAuthorizerResult } from 'aws-lambda'; +import { hashApiKeySecret, parseApiKey, timingSafeHashEqual } from './shared/api-key'; +import { logger } from './shared/logger'; +import type { ApiKeyRecord } from './shared/types'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const TABLE_NAME = process.env.API_KEY_TABLE_NAME!; + +/** + * Scope this authorizer instance gates. API keys must hold it; Cognito JWTs + * (interactive users) are exempt — a logged-in user manages their own + * resources under the existing user-scoped model. Configured per route group + * so a Phase 2 instance can gate task routes with `tasks:read`. + */ +const REQUIRED_SCOPE = process.env.API_KEY_REQUIRED_SCOPE!; + +// Verifier is created once per container and caches the pool JWKS across warm +// invocations. `tokenUse: 'id'` matches the id_token the bgagent CLI sends. +const jwtVerifier = CognitoJwtVerifier.create({ + userPoolId: process.env.USER_POOL_ID!, + tokenUse: 'id', + clientId: process.env.APP_CLIENT_ID!, +}); + +function generatePolicy( + principalId: string, + effect: 'Allow' | 'Deny', + resource: string, + context?: Record, +): APIGatewayAuthorizerResult { + return { + principalId, + policyDocument: { + Version: '2012-10-17', + Statement: [{ + Action: 'execute-api:Invoke', + Effect: effect, + Resource: resource, + }], + }, + ...(context && { context }), + }; +} + +function header(event: APIGatewayRequestAuthorizerEvent, name: string): string | undefined { + const headers = event.headers ?? {}; + return headers[name] ?? headers[name.toLowerCase()]; +} + +/** + * Lambda REQUEST authorizer accepting EITHER a platform API key (`X-API-Key`) + * OR a Cognito id token (`Authorization`). Returns `principalId = user_id` and + * `context.userId`, which handlers read via `extractUserId` — identical to the + * Cognito-authorizer path, so the webhook management handlers are unchanged. + */ +export async function handler(event: APIGatewayRequestAuthorizerEvent): Promise { + const methodArn = event.methodArn; + const apiKeyHeader = header(event, 'X-API-Key'); + const authHeader = header(event, 'Authorization'); + + try { + if (apiKeyHeader) { + return await authorizeApiKey(apiKeyHeader, methodArn); + } + if (authHeader) { + return await authorizeJwt(authHeader, methodArn); + } + logger.warn('No credential presented (missing X-API-Key and Authorization)'); + return generatePolicy('anonymous', 'Deny', methodArn); + } catch (err) { + logger.error('Authorizer unexpected error', { error: String(err) }); + return generatePolicy('anonymous', 'Deny', methodArn); + } +} + +async function authorizeApiKey( + presented: string, + methodArn: string, +): Promise { + const parsed = parseApiKey(presented); + if (!parsed) { + logger.warn('Malformed API key'); + return generatePolicy('anonymous', 'Deny', methodArn); + } + + // Direct GetItem by partition key (strongly consistent) — a revoked key stops + // authenticating immediately, no GSI eventual-consistency window. + const result = await ddb.send(new GetCommand({ + TableName: TABLE_NAME, + Key: { key_id: parsed.keyId }, + ConsistentRead: true, + })); + + const record = result.Item as ApiKeyRecord | undefined; + if (!record || record.status !== 'active') { + logger.warn('API key not found or revoked', { key_id: parsed.keyId }); + return generatePolicy(parsed.keyId, 'Deny', methodArn); + } + + if (record.expires_at && Date.parse(record.expires_at) <= Date.now()) { + logger.warn('API key expired', { key_id: parsed.keyId }); + return generatePolicy(parsed.keyId, 'Deny', methodArn); + } + + if (!timingSafeHashEqual(hashApiKeySecret(parsed.secret), record.key_hash)) { + logger.warn('API key secret mismatch', { key_id: parsed.keyId }); + return generatePolicy(parsed.keyId, 'Deny', methodArn); + } + + const scopes: readonly string[] = record.scopes; + if (!scopes.includes(REQUIRED_SCOPE)) { + logger.warn('API key missing required scope', { + key_id: parsed.keyId, + required_scope: REQUIRED_SCOPE, + }); + return generatePolicy(record.user_id, 'Deny', methodArn); + } + + return generatePolicy(record.user_id, 'Allow', methodArn, { + userId: record.user_id, + keyId: record.key_id, + scopes: record.scopes.join(','), + }); +} + +async function authorizeJwt( + authHeader: string, + methodArn: string, +): Promise { + const token = authHeader.replace(/^Bearer\s+/i, '').trim(); + if (!token) { + logger.warn('Empty Authorization header'); + return generatePolicy('anonymous', 'Deny', methodArn); + } + + try { + const payload = await jwtVerifier.verify(token); + return generatePolicy(payload.sub, 'Allow', methodArn, { userId: payload.sub }); + } catch (err) { + logger.warn('JWT verification failed', { error: String(err) }); + return generatePolicy('anonymous', 'Deny', methodArn); + } +} diff --git a/cdk/src/handlers/create-api-key.ts b/cdk/src/handlers/create-api-key.ts new file mode 100644 index 00000000..667aaace --- /dev/null +++ b/cdk/src/handlers/create-api-key.ts @@ -0,0 +1,126 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { ulid } from 'ulid'; +import { generateApiKey, validateScopes } from './shared/api-key'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import type { ApiKeyRecord, ApiKeyScope, CreateApiKeyRequest, CreateApiKeyResponse } from './shared/types'; +import { isValidWebhookName, parseBody } from './shared/validation'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const TABLE_NAME = process.env.API_KEY_TABLE_NAME!; + +/** Default scope granted when the caller omits `scopes`. */ +const DEFAULT_SCOPES: readonly ApiKeyScope[] = ['webhooks:manage']; + +/** + * POST /v1/api-keys — Mint a new platform API key for the authenticated user. + * The plaintext key is returned once; only its hash is persisted. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + + try { + const userId = extractUserId(event); + if (!userId) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Missing or invalid authentication.', requestId); + } + + const body = parseBody(event.body); + if (!body) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Request body must be valid JSON.', requestId); + } + + if (!body.name || !isValidWebhookName(body.name)) { + return errorResponse( + 400, + ErrorCode.VALIDATION_ERROR, + 'Invalid key name. Must be 1-64 alphanumeric characters, spaces, hyphens, or underscores.', + requestId, + ); + } + + let scopes: readonly ApiKeyScope[] = DEFAULT_SCOPES; + if (body.scopes !== undefined) { + if (!Array.isArray(body.scopes) || body.scopes.length === 0) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'scopes must be a non-empty array.', requestId); + } + const validated = validateScopes(body.scopes); + if (!validated) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'scopes contains an unknown value.', requestId); + } + scopes = validated; + } + + let expiresAt: string | undefined; + if (body.expires_at !== undefined) { + const parsed = Date.parse(body.expires_at); + if (Number.isNaN(parsed)) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'expires_at must be an ISO-8601 timestamp.', requestId); + } + if (parsed <= Date.now()) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'expires_at must be in the future.', requestId); + } + expiresAt = new Date(parsed).toISOString(); + } + + const keyId = ulid(); + const { plaintext, keyHash } = generateApiKey(keyId); + const now = new Date().toISOString(); + + const record: ApiKeyRecord = { + key_id: keyId, + user_id: userId, + name: body.name, + key_hash: keyHash, + scopes, + status: 'active', + created_at: now, + updated_at: now, + ...(expiresAt && { expires_at: expiresAt }), + }; + + await ddb.send(new PutCommand({ + TableName: TABLE_NAME, + Item: record, + ConditionExpression: 'attribute_not_exists(key_id)', + })); + + logger.info('API key created', { key_id: keyId, user_id: userId, request_id: requestId }); + + const response: CreateApiKeyResponse = { + key_id: keyId, + name: body.name, + key: plaintext, + scopes, + expires_at: expiresAt ?? null, + created_at: now, + }; + + return successResponse(201, response, requestId); + } catch (err) { + logger.error('Failed to create API key', { error: String(err), request_id: requestId }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId); + } +} diff --git a/cdk/src/handlers/delete-api-key.ts b/cdk/src/handlers/delete-api-key.ts new file mode 100644 index 00000000..6d99e6e8 --- /dev/null +++ b/cdk/src/handlers/delete-api-key.ts @@ -0,0 +1,86 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { ulid } from 'ulid'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import { type ApiKeyRecord, toApiKeyDetail } from './shared/types'; +import { computeTtlEpoch } from './shared/validation'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const TABLE_NAME = process.env.API_KEY_TABLE_NAME!; +const API_KEY_RETENTION_DAYS = Number(process.env.API_KEY_RETENTION_DAYS ?? '30'); + +/** + * DELETE /v1/api-keys/{key_id} — Soft-revoke an API key. + * + * Mirrors webhook revocation: ownership mismatch returns 404 (not 403) so the + * API is not an existence oracle for another user's key IDs. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + + try { + const userId = extractUserId(event); + if (!userId) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Missing or invalid authentication.', requestId); + } + + const keyId = event.pathParameters?.key_id; + if (!keyId) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Missing key_id path parameter.', requestId); + } + + const result = await ddb.send(new GetCommand({ + TableName: TABLE_NAME, + Key: { key_id: keyId }, + })); + + const record = result.Item as ApiKeyRecord | undefined; + if (!record || record.user_id !== userId) { + return errorResponse(404, ErrorCode.API_KEY_NOT_FOUND, 'API key not found.', requestId); + } + + if (record.status === 'revoked') { + return errorResponse(409, ErrorCode.API_KEY_ALREADY_REVOKED, 'API key is already revoked.', requestId); + } + + const now = new Date().toISOString(); + + const updated = await ddb.send(new UpdateCommand({ + TableName: TABLE_NAME, + Key: { key_id: keyId }, + UpdateExpression: 'SET #s = :revoked, updated_at = :now, revoked_at = :now, #ttl = :ttl', + ExpressionAttributeNames: { '#s': 'status', '#ttl': 'ttl' }, + ExpressionAttributeValues: { ':revoked': 'revoked', ':now': now, ':ttl': computeTtlEpoch(API_KEY_RETENTION_DAYS) }, + ReturnValues: 'ALL_NEW', + })); + + logger.info('API key revoked', { key_id: keyId, user_id: userId, request_id: requestId }); + + return successResponse(200, toApiKeyDetail(updated.Attributes as ApiKeyRecord), requestId); + } catch (err) { + logger.error('Failed to delete API key', { error: String(err), request_id: requestId }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId); + } +} diff --git a/cdk/src/handlers/list-api-keys.ts b/cdk/src/handlers/list-api-keys.ts new file mode 100644 index 00000000..c903927a --- /dev/null +++ b/cdk/src/handlers/list-api-keys.ts @@ -0,0 +1,85 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { ulid } from 'ulid'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { ErrorCode, errorResponse, paginatedResponse } from './shared/response'; +import { type ApiKeyRecord, toApiKeyDetail } from './shared/types'; +import { decodePaginationToken, encodePaginationToken, parseLimit } from './shared/validation'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const TABLE_NAME = process.env.API_KEY_TABLE_NAME!; + +/** Default page size when the caller omits ``?limit=``. */ +const DEFAULT_PAGE_LIMIT = 20; + +/** Hard page-size ceiling. */ +const MAX_PAGE_LIMIT = 100; + +/** + * GET /v1/api-keys — List API keys for the authenticated user. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + + try { + const userId = extractUserId(event); + if (!userId) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Missing or invalid authentication.', requestId); + } + + const limit = parseLimit(event.queryStringParameters?.limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT); + const startKey = decodePaginationToken(event.queryStringParameters?.next_token); + const includeRevoked = event.queryStringParameters?.include_revoked === 'true'; + + let filterExpression: string | undefined; + const expressionAttributeValues: Record = { ':uid': userId }; + + if (!includeRevoked) { + filterExpression = '#s = :active'; + expressionAttributeValues[':active'] = 'active'; + } + + const result = await ddb.send(new QueryCommand({ + TableName: TABLE_NAME, + IndexName: 'UserIndex', + KeyConditionExpression: 'user_id = :uid', + ...(filterExpression && { + FilterExpression: filterExpression, + ExpressionAttributeNames: { '#s': 'status' }, + }), + ExpressionAttributeValues: expressionAttributeValues, + Limit: limit, + ScanIndexForward: false, + ...(startKey && { ExclusiveStartKey: startKey }), + })); + + const items = (result.Items ?? []) as ApiKeyRecord[]; + const nextToken = encodePaginationToken(result.LastEvaluatedKey as Record | undefined); + + return paginatedResponse(items.map(toApiKeyDetail), nextToken, requestId); + } catch (err) { + logger.error('Failed to list API keys', { error: String(err), request_id: requestId }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId); + } +} diff --git a/cdk/src/handlers/shared/api-key.ts b/cdk/src/handlers/shared/api-key.ts new file mode 100644 index 00000000..777df870 --- /dev/null +++ b/cdk/src/handlers/shared/api-key.ts @@ -0,0 +1,114 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { API_KEY_SCOPES, type ApiKeyScope } from './types'; + +/** + * Prefix on every platform API key. Lets secret scanners (e.g. gitleaks) + * recognize a leaked key and lets callers tell key material apart from a JWT. + */ +export const API_KEY_PREFIX = 'bgak'; + +/** Secret entropy per key (bytes; 256-bit). */ +export const API_KEY_SECRET_BYTES = 32; + +/** Number of underscore-delimited segments in a well-formed key. */ +const KEY_PART_COUNT = 3; + +/** + * A minted key and the fields persisted for it. The `plaintext` is returned to + * the caller exactly once; only `keyHash` is stored. + */ +export interface GeneratedApiKey { + readonly keyId: string; + readonly plaintext: string; + readonly keyHash: string; +} + +/** SHA-256 hex digest of the secret portion of a key. */ +export function hashApiKeySecret(secret: string): string { + return crypto.createHash('sha256').update(secret, 'utf8').digest('hex'); +} + +/** + * Mint a new key for a given key_id. The wire format is + * `bgak__` so the authorizer can recover key_id without a + * secondary index and look the record up directly by partition key. + * @param keyId - the ULID partition key for the record. + * @returns the plaintext (return-once) and the hash to persist. + */ +export function generateApiKey(keyId: string): GeneratedApiKey { + const secret = crypto.randomBytes(API_KEY_SECRET_BYTES).toString('hex'); + return { + keyId, + plaintext: `${API_KEY_PREFIX}_${keyId}_${secret}`, + keyHash: hashApiKeySecret(secret), + }; +} + +/** Parsed components of a presented key. */ +export interface ParsedApiKey { + readonly keyId: string; + readonly secret: string; +} + +/** + * Parse a presented `bgak__` value into its parts. Returns null + * for any value that is not well-formed, so callers deny without branching on + * the specific failure (avoids leaking which part was wrong). + * @param presented - the raw `X-API-Key` header value. + * @returns the parsed key_id and secret, or null if malformed. + */ +export function parseApiKey(presented: string): ParsedApiKey | null { + const parts = presented.split('_'); + if (parts.length !== KEY_PART_COUNT) return null; + const [prefix, keyId, secret] = parts; + if (prefix !== API_KEY_PREFIX) return null; + if (!keyId || !secret) return null; + return { keyId, secret }; +} + +/** + * Constant-time comparison of two hex-encoded hashes. Both are fixed-length + * (SHA-256 → 64 hex chars) so a length mismatch means malformed input, not a + * timing signal. + * @param a - first hex digest. + * @param b - second hex digest. + * @returns true if the digests are byte-for-byte equal. + */ +export function timingSafeHashEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')); +} + +/** + * Validate a requested scope list against the known vocabulary. + * @param scopes - the raw scopes from a create request. + * @returns the validated scopes, or null if any value is unrecognized. + */ +export function validateScopes(scopes: readonly string[]): ApiKeyScope[] | null { + const known = new Set(API_KEY_SCOPES); + const out: ApiKeyScope[] = []; + for (const s of scopes) { + if (!known.has(s)) return null; + out.push(s as ApiKeyScope); + } + return out; +} diff --git a/cdk/src/handlers/shared/gateway.ts b/cdk/src/handlers/shared/gateway.ts index efcfb4e3..cdfa5b3a 100644 --- a/cdk/src/handlers/shared/gateway.ts +++ b/cdk/src/handlers/shared/gateway.ts @@ -21,14 +21,23 @@ import type { APIGatewayProxyEvent } from 'aws-lambda'; /** * Extract the authenticated user_id from the API Gateway event. - * The Cognito authorizer places claims in event.requestContext.authorizer.claims. + * + * Two authorizer shapes place the identity in different spots: + * - The native Cognito authorizer sets `authorizer.claims.sub`. + * - A custom REQUEST authorizer (webhook / API-key) sets `authorizer.userId` + * as a top-level context value. + * + * Both resolve to the same IdP-namespaced platform user ID, so a handler behind + * either authorizer reads identity through this one call. * @param event - the API Gateway proxy event. - * @returns the Cognito `sub` claim (platform user ID), or null if missing. + * @returns the platform user ID, or null if missing. */ export function extractUserId(event: APIGatewayProxyEvent): string | null { - const claims = event.requestContext.authorizer?.claims; - if (!claims || typeof claims.sub !== 'string') return null; - return claims.sub; + const authorizer = event.requestContext.authorizer; + if (!authorizer) return null; + if (typeof authorizer.claims?.sub === 'string') return authorizer.claims.sub; + if (typeof authorizer.userId === 'string') return authorizer.userId; + return null; } /** diff --git a/cdk/src/handlers/shared/response.ts b/cdk/src/handlers/shared/response.ts index f4d37087..481211f7 100644 --- a/cdk/src/handlers/shared/response.ts +++ b/cdk/src/handlers/shared/response.ts @@ -33,6 +33,8 @@ export const ErrorCode = { RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED', WEBHOOK_NOT_FOUND: 'WEBHOOK_NOT_FOUND', WEBHOOK_ALREADY_REVOKED: 'WEBHOOK_ALREADY_REVOKED', + API_KEY_NOT_FOUND: 'API_KEY_NOT_FOUND', + API_KEY_ALREADY_REVOKED: 'API_KEY_ALREADY_REVOKED', REPO_NOT_ONBOARDED: 'REPO_NOT_ONBOARDED', SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE', INTERNAL_ERROR: 'INTERNAL_ERROR', diff --git a/cdk/src/handlers/shared/types.ts b/cdk/src/handlers/shared/types.ts index dc4d9c84..daceca13 100644 --- a/cdk/src/handlers/shared/types.ts +++ b/cdk/src/handlers/shared/types.ts @@ -879,6 +879,95 @@ export function toWebhookDetail(record: WebhookRecord): WebhookDetail { }; } +/** + * Scopes a platform API key may hold. Phase 1 ships `webhooks:manage`; + * the remaining values are reserved for Phase 2 (read-only task APIs) so keys + * minted now can be validated against a stable vocabulary. + */ +export type ApiKeyScope = 'webhooks:manage' | 'webhooks:invoke' | 'tasks:read' | 'tasks:cancel'; + +/** Scopes recognized by the platform. Order is not significant. */ +export const API_KEY_SCOPES: readonly ApiKeyScope[] = [ + 'webhooks:manage', + 'webhooks:invoke', + 'tasks:read', + 'tasks:cancel', +]; + +/** + * Full platform API key record as stored in DynamoDB. + * + * `key_hash` is the SHA-256 hex digest of the secret portion of the presented + * key. The raw secret is never stored — it is returned once at creation. + */ +export interface ApiKeyRecord { + readonly key_id: string; + readonly user_id: string; + readonly name: string; + readonly key_hash: string; + readonly scopes: readonly ApiKeyScope[]; + readonly status: 'active' | 'revoked'; + readonly created_at: string; + readonly updated_at: string; + readonly expires_at?: string; + readonly revoked_at?: string; + readonly ttl?: number; +} + +/** + * Platform API key detail for API responses. Excludes `key_hash` and `user_id`. + */ +export interface ApiKeyDetail { + readonly key_id: string; + readonly name: string; + readonly scopes: readonly ApiKeyScope[]; + readonly status: 'active' | 'revoked'; + readonly created_at: string; + readonly updated_at: string; + readonly expires_at: string | null; + readonly revoked_at: string | null; +} + +/** + * Create API key request body. + */ +export interface CreateApiKeyRequest { + readonly name: string; + readonly scopes?: readonly ApiKeyScope[]; + readonly expires_at?: string; +} + +/** + * Create API key response — includes the full key material (shown only once). + * The `key` is the value the caller sends in the `X-API-Key` header. + */ +export interface CreateApiKeyResponse { + readonly key_id: string; + readonly name: string; + readonly key: string; + readonly scopes: readonly ApiKeyScope[]; + readonly expires_at: string | null; + readonly created_at: string; +} + +/** + * Map a DynamoDB API key record to the API detail response shape. + * @param record - the DynamoDB API key record. + * @returns the API-facing API key detail (never includes the hash or owner). + */ +export function toApiKeyDetail(record: ApiKeyRecord): ApiKeyDetail { + return { + key_id: record.key_id, + name: record.name, + scopes: record.scopes, + status: record.status, + created_at: record.created_at, + updated_at: record.updated_at, + expires_at: record.expires_at ?? null, + revoked_at: record.revoked_at ?? null, + }; +} + /** * Map a DynamoDB task record to the API summary response shape. * @param record - the DynamoDB task record. diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 1382dce4..a7ebdb86 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -33,6 +33,7 @@ import { Construct, IConstruct } from 'constructs'; import { AgentMemory } from '../constructs/agent-memory'; import { AgentSessionRole } from '../constructs/agent-session-role'; import { AgentVpc } from '../constructs/agent-vpc'; +import { ApiKeyTable } from '../constructs/api-key-table'; import { ApprovalMetricsPublisherConsumer } from '../constructs/approval-metrics-publisher-consumer'; import { AttachmentsBucket } from '../constructs/attachments-bucket'; import { resolveBedrockModelIds } from '../constructs/bedrock-models'; @@ -106,6 +107,7 @@ export class AgentStack extends Stack { const taskApprovalsTable = new TaskApprovalsTable(this, 'TaskApprovalsTableV2'); const userConcurrencyTable = new UserConcurrencyTable(this, 'UserConcurrencyTable'); const webhookTable = new WebhookTable(this, 'WebhookTable'); + const apiKeyTable = new ApiKeyTable(this, 'ApiKeyTable'); const repoTable = new RepoTable(this, 'RepoTable'); // Cedar-wasm Lambda layer (§15.2 task 10). Instantiated here so the @@ -292,6 +294,7 @@ export class AgentStack extends Stack { cedarWasmLayer: cedarWasmLayer.layer, repoTable: repoTable.table, webhookTable: webhookTable.table, + apiKeyTable: apiKeyTable.table, orchestratorFunctionArn: lazyOrchestratorArn, guardrailId: inputGuardrail.guardrailId, guardrailVersion: inputGuardrail.guardrailVersion, @@ -562,6 +565,11 @@ export class AgentStack extends Stack { description: 'Name of the DynamoDB webhook table', }); + new CfnOutput(this, 'ApiKeyTableName', { + value: apiKeyTable.table.tableName, + description: 'Name of the DynamoDB platform API key table', + }); + new CfnOutput(this, 'RepoTableName', { value: repoTable.table.tableName, description: 'Name of the DynamoDB repo config table', diff --git a/cdk/test/constructs/api-key-table.test.ts b/cdk/test/constructs/api-key-table.test.ts new file mode 100644 index 00000000..beefe3b9 --- /dev/null +++ b/cdk/test/constructs/api-key-table.test.ts @@ -0,0 +1,63 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, Stack } from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import { ApiKeyTable } from '../../src/constructs/api-key-table'; + +describe('ApiKeyTable construct', () => { + let template: Template; + + beforeAll(() => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new ApiKeyTable(stack, 'ApiKeyTable'); + template = Template.fromStack(stack); + }); + + test('creates a DynamoDB table with key_id partition key', () => { + template.resourceCountIs('AWS::DynamoDB::Table', 1); + template.hasResourceProperties('AWS::DynamoDB::Table', { + KeySchema: [{ AttributeName: 'key_id', KeyType: 'HASH' }], + BillingMode: 'PAY_PER_REQUEST', + }); + }); + + test('enables PITR and TTL', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: true }, + TimeToLiveSpecification: { AttributeName: 'ttl', Enabled: true }, + }); + }); + + test('has a UserIndex GSI keyed by user_id + created_at', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + GlobalSecondaryIndexes: [ + { + IndexName: 'UserIndex', + KeySchema: [ + { AttributeName: 'user_id', KeyType: 'HASH' }, + { AttributeName: 'created_at', KeyType: 'RANGE' }, + ], + Projection: { ProjectionType: 'ALL' }, + }, + ], + }); + }); +}); diff --git a/cdk/test/handlers/api-key-authorizer.test.ts b/cdk/test/handlers/api-key-authorizer.test.ts new file mode 100644 index 00000000..3fa3d184 --- /dev/null +++ b/cdk/test/handlers/api-key-authorizer.test.ts @@ -0,0 +1,212 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import type { APIGatewayRequestAuthorizerEvent } from 'aws-lambda'; + +const mockDdbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: mockDdbSend })) }, + GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), +})); + +const mockJwtVerify = jest.fn(); +jest.mock('aws-jwt-verify', () => ({ + CognitoJwtVerifier: { create: jest.fn(() => ({ verify: mockJwtVerify })) }, +})); + +process.env.API_KEY_TABLE_NAME = 'ApiKeys'; +process.env.API_KEY_REQUIRED_SCOPE = 'webhooks:manage'; +process.env.USER_POOL_ID = 'us-east-1_pool'; +process.env.APP_CLIENT_ID = 'client-abc'; + +import { handler } from '../../src/handlers/api-key-authorizer'; + +const METHOD_ARN = 'arn:aws:execute-api:us-east-1:123456789012:api-id/v1/POST/webhooks'; +const KEY_ID = '01J000000000000000000KEYID'; +const SECRET = 'a'.repeat(64); +const RAW_KEY = `bgak_${KEY_ID}_${SECRET}`; +const KEY_HASH = crypto.createHash('sha256').update(SECRET, 'utf8').digest('hex'); + +function activeRecord(overrides: Record = {}) { + return { + Item: { + key_id: KEY_ID, + user_id: 'cognito+user-abc', + name: 'ci', + key_hash: KEY_HASH, + scopes: ['webhooks:manage'], + status: 'active', + ...overrides, + }, + }; +} + +function makeEvent(headers: Record): APIGatewayRequestAuthorizerEvent { + return { + type: 'REQUEST', + methodArn: METHOD_ARN, + resource: '/webhooks', + path: '/v1/webhooks', + httpMethod: 'POST', + headers, + multiValueHeaders: {}, + pathParameters: {}, + queryStringParameters: {}, + multiValueQueryStringParameters: {}, + stageVariables: {}, + requestContext: {} as never, + } as APIGatewayRequestAuthorizerEvent; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockDdbSend.mockResolvedValue(activeRecord()); +}); + +describe('api-key-authorizer — API key path', () => { + test('Allow for a valid, scoped, active key; returns owner + context', async () => { + const result = await handler(makeEvent({ 'X-API-Key': RAW_KEY })); + expect(result.policyDocument.Statement[0].Effect).toBe('Allow'); + expect(result.principalId).toBe('cognito+user-abc'); + expect(result.context?.userId).toBe('cognito+user-abc'); + expect(result.context?.keyId).toBe(KEY_ID); + expect(result.context?.scopes).toBe('webhooks:manage'); + }); + + test('reads the header case-insensitively', async () => { + const result = await handler(makeEvent({ 'x-api-key': RAW_KEY })); + expect(result.policyDocument.Statement[0].Effect).toBe('Allow'); + }); + + test('does a strongly-consistent GetItem by key_id', async () => { + await handler(makeEvent({ 'X-API-Key': RAW_KEY })); + const sent = mockDdbSend.mock.calls[0][0]; + expect(sent.input.Key).toEqual({ key_id: KEY_ID }); + expect(sent.input.ConsistentRead).toBe(true); + }); + + test('Deny for a malformed key (no lookup performed)', async () => { + const result = await handler(makeEvent({ 'X-API-Key': 'not-a-valid-key' })); + expect(result.policyDocument.Statement[0].Effect).toBe('Deny'); + expect(mockDdbSend).not.toHaveBeenCalled(); + }); + + test('Deny for wrong prefix', async () => { + const result = await handler(makeEvent({ 'X-API-Key': `xxxx_${KEY_ID}_${SECRET}` })); + expect(result.policyDocument.Statement[0].Effect).toBe('Deny'); + expect(mockDdbSend).not.toHaveBeenCalled(); + }); + + test('Deny when the key is not found', async () => { + mockDdbSend.mockResolvedValueOnce({ Item: undefined }); + const result = await handler(makeEvent({ 'X-API-Key': RAW_KEY })); + expect(result.policyDocument.Statement[0].Effect).toBe('Deny'); + }); + + test('Deny when the key is revoked', async () => { + mockDdbSend.mockResolvedValueOnce(activeRecord({ status: 'revoked' })); + const result = await handler(makeEvent({ 'X-API-Key': RAW_KEY })); + expect(result.policyDocument.Statement[0].Effect).toBe('Deny'); + }); + + test('Deny when the secret does not match the stored hash', async () => { + const wrong = `bgak_${KEY_ID}_${'b'.repeat(64)}`; + const result = await handler(makeEvent({ 'X-API-Key': wrong })); + expect(result.policyDocument.Statement[0].Effect).toBe('Deny'); + }); + + test('Deny when the key is expired', async () => { + mockDdbSend.mockResolvedValueOnce( + activeRecord({ expires_at: new Date(Date.now() - 1000).toISOString() }), + ); + const result = await handler(makeEvent({ 'X-API-Key': RAW_KEY })); + expect(result.policyDocument.Statement[0].Effect).toBe('Deny'); + }); + + test('Allow when expires_at is in the future', async () => { + mockDdbSend.mockResolvedValueOnce( + activeRecord({ expires_at: new Date(Date.now() + 3_600_000).toISOString() }), + ); + const result = await handler(makeEvent({ 'X-API-Key': RAW_KEY })); + expect(result.policyDocument.Statement[0].Effect).toBe('Allow'); + }); + + test('Deny when the key lacks the required scope', async () => { + mockDdbSend.mockResolvedValueOnce(activeRecord({ scopes: ['tasks:read'] })); + const result = await handler(makeEvent({ 'X-API-Key': RAW_KEY })); + expect(result.policyDocument.Statement[0].Effect).toBe('Deny'); + }); + + test('Deny (not throw) on an unexpected DynamoDB error', async () => { + mockDdbSend.mockRejectedValueOnce(new Error('DDB down')); + const result = await handler(makeEvent({ 'X-API-Key': RAW_KEY })); + expect(result.policyDocument.Statement[0].Effect).toBe('Deny'); + }); + + test('does not attempt JWT verification when X-API-Key is present', async () => { + await handler(makeEvent({ 'X-API-Key': RAW_KEY })); + expect(mockJwtVerify).not.toHaveBeenCalled(); + }); +}); + +describe('api-key-authorizer — JWT path', () => { + test('Allow for a valid Cognito JWT; principal is the sub', async () => { + mockJwtVerify.mockResolvedValueOnce({ sub: 'cognito+jwt-user' }); + const result = await handler(makeEvent({ Authorization: 'jwt-token' })); + expect(result.policyDocument.Statement[0].Effect).toBe('Allow'); + expect(result.principalId).toBe('cognito+jwt-user'); + expect(result.context?.userId).toBe('cognito+jwt-user'); + expect(mockDdbSend).not.toHaveBeenCalled(); + }); + + test('strips a Bearer prefix before verifying', async () => { + mockJwtVerify.mockResolvedValueOnce({ sub: 'cognito+jwt-user' }); + await handler(makeEvent({ Authorization: 'Bearer jwt-token' })); + expect(mockJwtVerify).toHaveBeenCalledWith('jwt-token'); + }); + + test('Deny when JWT verification fails', async () => { + mockJwtVerify.mockRejectedValueOnce(new Error('bad signature')); + const result = await handler(makeEvent({ Authorization: 'jwt-token' })); + expect(result.policyDocument.Statement[0].Effect).toBe('Deny'); + }); + + test('Deny for an empty Authorization value', async () => { + const result = await handler(makeEvent({ Authorization: ' ' })); + expect(result.policyDocument.Statement[0].Effect).toBe('Deny'); + expect(mockJwtVerify).not.toHaveBeenCalled(); + }); +}); + +describe('api-key-authorizer — no credential', () => { + test('Deny when neither header is present', async () => { + const result = await handler(makeEvent({})); + expect(result.policyDocument.Statement[0].Effect).toBe('Deny'); + expect(mockDdbSend).not.toHaveBeenCalled(); + expect(mockJwtVerify).not.toHaveBeenCalled(); + }); + + test('prefers the API key when both headers are present', async () => { + await handler(makeEvent({ 'X-API-Key': RAW_KEY, 'Authorization': 'jwt-token' })); + expect(mockDdbSend).toHaveBeenCalled(); + expect(mockJwtVerify).not.toHaveBeenCalled(); + }); +}); diff --git a/cdk/test/handlers/create-api-key.test.ts b/cdk/test/handlers/create-api-key.test.ts new file mode 100644 index 00000000..2cfd8d75 --- /dev/null +++ b/cdk/test/handlers/create-api-key.test.ts @@ -0,0 +1,148 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { APIGatewayProxyEvent } from 'aws-lambda'; + +const mockDdbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: mockDdbSend })) }, + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), +})); + +let ulidCounter = 0; +jest.mock('ulid', () => ({ ulid: jest.fn(() => `ULID${ulidCounter++}`) })); + +process.env.API_KEY_TABLE_NAME = 'ApiKeys'; + +import { handler } from '../../src/handlers/create-api-key'; + +function makeEvent(overrides: Partial = {}): APIGatewayProxyEvent { + return { + body: JSON.stringify({ name: 'CI key' }), + headers: {}, + multiValueHeaders: {}, + httpMethod: 'POST', + isBase64Encoded: false, + path: '/v1/api-keys', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + resource: '/api-keys', + requestContext: { + authorizer: { claims: { sub: 'user-123' } }, + } as never, + ...overrides, + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + ulidCounter = 0; + mockDdbSend.mockResolvedValue({}); +}); + +describe('create-api-key handler', () => { + test('creates a key and returns the plaintext once', async () => { + const result = await handler(makeEvent()); + expect(result.statusCode).toBe(201); + const body = JSON.parse(result.body); + expect(body.data.key_id).toBeDefined(); + expect(body.data.name).toBe('CI key'); + expect(body.data.key).toMatch(/^bgak_/); + expect(body.data.scopes).toEqual(['webhooks:manage']); + expect(body.data.expires_at).toBeNull(); + expect(mockDdbSend).toHaveBeenCalledTimes(1); + }); + + test('persists only the hash, never the plaintext secret', async () => { + const result = await handler(makeEvent()); + const plaintext = JSON.parse(result.body).data.key; + const putItem = mockDdbSend.mock.calls[0][0].input.Item; + expect(putItem.key_hash).toBeDefined(); + expect(putItem).not.toHaveProperty('key'); + expect(putItem).not.toHaveProperty('secret'); + expect(JSON.stringify(putItem)).not.toContain(plaintext.split('_')[2]); + }); + + test('accepts a custom valid scope list', async () => { + const event = makeEvent({ body: JSON.stringify({ name: 'ops', scopes: ['tasks:read'] }) }); + const result = await handler(event); + expect(result.statusCode).toBe(201); + expect(JSON.parse(result.body).data.scopes).toEqual(['tasks:read']); + }); + + test('rejects an unknown scope', async () => { + const event = makeEvent({ body: JSON.stringify({ name: 'x', scopes: ['bogus:scope'] }) }); + const result = await handler(event); + expect(result.statusCode).toBe(400); + expect(mockDdbSend).not.toHaveBeenCalled(); + }); + + test('rejects an empty scope array', async () => { + const event = makeEvent({ body: JSON.stringify({ name: 'x', scopes: [] }) }); + const result = await handler(event); + expect(result.statusCode).toBe(400); + }); + + test('accepts a future expires_at and echoes it back', async () => { + const future = new Date(Date.now() + 86_400_000).toISOString(); + const event = makeEvent({ body: JSON.stringify({ name: 'x', expires_at: future }) }); + const result = await handler(event); + expect(result.statusCode).toBe(201); + expect(JSON.parse(result.body).data.expires_at).toBe(future); + }); + + test('rejects a past expires_at', async () => { + const past = new Date(Date.now() - 1000).toISOString(); + const event = makeEvent({ body: JSON.stringify({ name: 'x', expires_at: past }) }); + const result = await handler(event); + expect(result.statusCode).toBe(400); + }); + + test('rejects a non-ISO expires_at', async () => { + const event = makeEvent({ body: JSON.stringify({ name: 'x', expires_at: 'not-a-date' }) }); + const result = await handler(event); + expect(result.statusCode).toBe(400); + }); + + test('returns 401 when not authenticated', async () => { + const event = makeEvent(); + event.requestContext.authorizer = null; + const result = await handler(event); + expect(result.statusCode).toBe(401); + }); + + test('returns 400 for missing body', async () => { + const result = await handler(makeEvent({ body: null })); + expect(result.statusCode).toBe(400); + }); + + test('returns 400 for invalid name', async () => { + const result = await handler(makeEvent({ body: JSON.stringify({ name: '-bad' }) })); + expect(result.statusCode).toBe(400); + }); + + test('returns 500 when the DynamoDB write fails', async () => { + mockDdbSend.mockRejectedValueOnce(new Error('DDB error')); + const result = await handler(makeEvent()); + expect(result.statusCode).toBe(500); + }); +}); diff --git a/cdk/test/handlers/delete-api-key.test.ts b/cdk/test/handlers/delete-api-key.test.ts new file mode 100644 index 00000000..43a71a01 --- /dev/null +++ b/cdk/test/handlers/delete-api-key.test.ts @@ -0,0 +1,111 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { APIGatewayProxyEvent } from 'aws-lambda'; + +const mockDdbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: mockDdbSend })) }, + GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), + UpdateCommand: jest.fn((input: unknown) => ({ _type: 'Update', input })), +})); + +jest.mock('ulid', () => ({ ulid: jest.fn(() => 'REQ') })); + +process.env.API_KEY_TABLE_NAME = 'ApiKeys'; + +import { handler } from '../../src/handlers/delete-api-key'; + +const KEY_ID = 'key-abc'; + +function makeEvent(overrides: Partial = {}): APIGatewayProxyEvent { + return { + body: null, + headers: {}, + multiValueHeaders: {}, + httpMethod: 'DELETE', + isBase64Encoded: false, + path: `/v1/api-keys/${KEY_ID}`, + pathParameters: { key_id: KEY_ID }, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + resource: '/api-keys/{key_id}', + requestContext: { + authorizer: { claims: { sub: 'owner-1' } }, + } as never, + ...overrides, + }; +} + +const activeItem = { + Item: { key_id: KEY_ID, user_id: 'owner-1', name: 'ci', status: 'active', scopes: ['webhooks:manage'] }, +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('delete-api-key handler', () => { + test('soft-revokes an owned active key', async () => { + mockDdbSend + .mockResolvedValueOnce(activeItem) + .mockResolvedValueOnce({ Attributes: { ...activeItem.Item, status: 'revoked', updated_at: 'now', revoked_at: 'now' } }); + const result = await handler(makeEvent()); + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body).data.status).toBe('revoked'); + const update = mockDdbSend.mock.calls[1][0]; + expect(update._type).toBe('Update'); + }); + + test('returns 404 (not 403) when the key belongs to another user', async () => { + mockDdbSend.mockResolvedValueOnce({ Item: { ...activeItem.Item, user_id: 'someone-else' } }); + const result = await handler(makeEvent()); + expect(result.statusCode).toBe(404); + expect(JSON.parse(result.body).error.code).toBe('API_KEY_NOT_FOUND'); + // must not attempt the update + expect(mockDdbSend).toHaveBeenCalledTimes(1); + }); + + test('returns 404 when the key does not exist', async () => { + mockDdbSend.mockResolvedValueOnce({ Item: undefined }); + const result = await handler(makeEvent()); + expect(result.statusCode).toBe(404); + }); + + test('returns 409 when the key is already revoked', async () => { + mockDdbSend.mockResolvedValueOnce({ Item: { ...activeItem.Item, status: 'revoked' } }); + const result = await handler(makeEvent()); + expect(result.statusCode).toBe(409); + expect(JSON.parse(result.body).error.code).toBe('API_KEY_ALREADY_REVOKED'); + }); + + test('returns 401 when unauthenticated', async () => { + const event = makeEvent(); + event.requestContext.authorizer = null; + const result = await handler(event); + expect(result.statusCode).toBe(401); + }); + + test('returns 400 when key_id path param is missing', async () => { + const result = await handler(makeEvent({ pathParameters: null })); + expect(result.statusCode).toBe(400); + }); +}); diff --git a/cdk/test/handlers/list-api-keys.test.ts b/cdk/test/handlers/list-api-keys.test.ts new file mode 100644 index 00000000..bb5146e9 --- /dev/null +++ b/cdk/test/handlers/list-api-keys.test.ts @@ -0,0 +1,114 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { APIGatewayProxyEvent } from 'aws-lambda'; + +const mockDdbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: mockDdbSend })) }, + QueryCommand: jest.fn((input: unknown) => ({ _type: 'Query', input })), +})); + +jest.mock('ulid', () => ({ ulid: jest.fn(() => 'REQ') })); + +process.env.API_KEY_TABLE_NAME = 'ApiKeys'; + +import { handler } from '../../src/handlers/list-api-keys'; + +function makeEvent(overrides: Partial = {}): APIGatewayProxyEvent { + return { + body: null, + headers: {}, + multiValueHeaders: {}, + httpMethod: 'GET', + isBase64Encoded: false, + path: '/v1/api-keys', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + resource: '/api-keys', + requestContext: { + authorizer: { claims: { sub: 'owner-1' } }, + } as never, + ...overrides, + }; +} + +const record = { + key_id: 'k1', + user_id: 'owner-1', + name: 'ci', + key_hash: 'deadbeef', + scopes: ['webhooks:manage'], + status: 'active', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockDdbSend.mockResolvedValue({ Items: [record] }); +}); + +describe('list-api-keys handler', () => { + test('lists keys for the caller and strips the hash + owner', async () => { + const result = await handler(makeEvent()); + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.data).toHaveLength(1); + expect(body.data[0].key_id).toBe('k1'); + expect(body.data[0]).not.toHaveProperty('key_hash'); + expect(body.data[0]).not.toHaveProperty('user_id'); + }); + + test('queries UserIndex keyed by the caller', async () => { + await handler(makeEvent()); + const q = mockDdbSend.mock.calls[0][0].input; + expect(q.IndexName).toBe('UserIndex'); + expect(q.ExpressionAttributeValues[':uid']).toBe('owner-1'); + }); + + test('filters to active keys by default', async () => { + await handler(makeEvent()); + const q = mockDdbSend.mock.calls[0][0].input; + expect(q.FilterExpression).toBe('#s = :active'); + expect(q.ExpressionAttributeValues[':active']).toBe('active'); + }); + + test('includes revoked keys when include_revoked=true', async () => { + await handler(makeEvent({ queryStringParameters: { include_revoked: 'true' } })); + const q = mockDdbSend.mock.calls[0][0].input; + expect(q.FilterExpression).toBeUndefined(); + }); + + test('returns 401 when unauthenticated', async () => { + const event = makeEvent(); + event.requestContext.authorizer = null; + const result = await handler(event); + expect(result.statusCode).toBe(401); + }); + + test('returns 500 on a DynamoDB error', async () => { + mockDdbSend.mockRejectedValueOnce(new Error('DDB error')); + const result = await handler(makeEvent()); + expect(result.statusCode).toBe(500); + }); +}); diff --git a/cdk/test/handlers/shared/api-key.test.ts b/cdk/test/handlers/shared/api-key.test.ts new file mode 100644 index 00000000..3fa8444d --- /dev/null +++ b/cdk/test/handlers/shared/api-key.test.ts @@ -0,0 +1,96 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { + API_KEY_PREFIX, + generateApiKey, + hashApiKeySecret, + parseApiKey, + timingSafeHashEqual, + validateScopes, +} from '../../../src/handlers/shared/api-key'; + +describe('generateApiKey', () => { + test('produces a bgak__ plaintext and a matching hash', () => { + const gen = generateApiKey('KEYID1'); + expect(gen.plaintext.startsWith(`${API_KEY_PREFIX}_KEYID1_`)).toBe(true); + const parsed = parseApiKey(gen.plaintext); + expect(parsed).not.toBeNull(); + expect(parsed!.keyId).toBe('KEYID1'); + expect(hashApiKeySecret(parsed!.secret)).toBe(gen.keyHash); + }); + + test('mints a distinct secret each time', () => { + const a = generateApiKey('K'); + const b = generateApiKey('K'); + expect(a.plaintext).not.toBe(b.plaintext); + expect(a.keyHash).not.toBe(b.keyHash); + }); + + test('the secret is 32 bytes of hex (64 chars)', () => { + const gen = generateApiKey('K'); + expect(parseApiKey(gen.plaintext)!.secret).toHaveLength(64); + }); +}); + +describe('parseApiKey', () => { + test('parses a well-formed key', () => { + expect(parseApiKey('bgak_abc_def')).toEqual({ keyId: 'abc', secret: 'def' }); + }); + + test.each([ + ['wrong prefix', 'xxxx_abc_def'], + ['too few parts', 'bgak_abc'], + ['too many parts', 'bgak_abc_def_ghi'], + ['empty key_id', 'bgak__def'], + ['empty secret', 'bgak_abc_'], + ['empty string', ''], + ])('returns null for %s', (_label, input) => { + expect(parseApiKey(input)).toBeNull(); + }); +}); + +describe('timingSafeHashEqual', () => { + test('true for identical digests', () => { + const h = hashApiKeySecret('x'); + expect(timingSafeHashEqual(h, h)).toBe(true); + }); + + test('false for different digests', () => { + expect(timingSafeHashEqual(hashApiKeySecret('a'), hashApiKeySecret('b'))).toBe(false); + }); + + test('false (no throw) for length mismatch', () => { + expect(timingSafeHashEqual('abc', 'abcd')).toBe(false); + }); +}); + +describe('validateScopes', () => { + test('accepts all known scopes', () => { + expect(validateScopes(['webhooks:manage', 'tasks:read'])).toEqual(['webhooks:manage', 'tasks:read']); + }); + + test('returns null when any scope is unknown', () => { + expect(validateScopes(['webhooks:manage', 'nope'])).toBeNull(); + }); + + test('accepts an empty list (caller decides emptiness policy)', () => { + expect(validateScopes([])).toEqual([]); + }); +}); diff --git a/cdk/test/handlers/shared/gateway.test.ts b/cdk/test/handlers/shared/gateway.test.ts index 6a76655a..17f4bf1b 100644 --- a/cdk/test/handlers/shared/gateway.test.ts +++ b/cdk/test/handlers/shared/gateway.test.ts @@ -96,6 +96,18 @@ describe('extractUserId', () => { event.requestContext.authorizer = { claims: { sub: 123 } }; expect(extractUserId(event)).toBeNull(); }); + + test('extracts userId from a custom authorizer context (webhook / API-key path)', () => { + const event = makeEvent(); + event.requestContext.authorizer = { userId: 'cognito+user-xyz' }; + expect(extractUserId(event)).toBe('cognito+user-xyz'); + }); + + test('prefers Cognito claims.sub over context userId when both present', () => { + const event = makeEvent(); + event.requestContext.authorizer = { claims: { sub: 'from-claims' }, userId: 'from-ctx' }; + expect(extractUserId(event)).toBe('from-claims'); + }); }); describe('generateBranchName', () => { diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index 3b8e6249..3098068f 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -39,13 +39,14 @@ describe('AgentStack', () => { test('creates exactly 18 DynamoDB tables', () => { // task, task-events, repo, user-concurrency, webhook, task-nudges, // task-approvals (Cedar HITL V2), + // api-key (platform API keys for headless webhook management, #376), // slack-installation, slack-user-mapping, // linear-project-mapping, linear-user-mapping, linear-webhook-dedup, // linear-workspace-registry (added in Phase 2.0b for OAuth bookkeeping), // jira-project-mapping, jira-user-mapping, jira-workspace-registry, // jira-webhook-dedup (added for the Jira Cloud integration), // github-webhook-dedup (added by GitHubScreenshotIntegration on main) - template.resourceCountIs('AWS::DynamoDB::Table', 18); + template.resourceCountIs('AWS::DynamoDB::Table', 19); }); test('creates TaskApprovalsTable with user_id-status-index GSI', () => { diff --git a/cli/src/api-client.ts b/cli/src/api-client.ts index 7d908d9a..c48b1be5 100644 --- a/cli/src/api-client.ts +++ b/cli/src/api-client.ts @@ -22,10 +22,13 @@ import { loadConfig } from './config'; import { debug, isVerbose, redactSensitive } from './debug'; import { ApiError, CliError } from './errors'; import { + ApiKeyDetail, ApprovalRequest, ApprovalResponse, ApprovalScope, CancelTaskResponse, + CreateApiKeyRequest, + CreateApiKeyResponse, CreateTaskRequest, CreateTaskResponse, CreateWebhookRequest, @@ -50,10 +53,27 @@ import { WebhookDetail, } from './types'; +/** Options for constructing an {@link ApiClient}. */ +export interface ApiClientOptions { + /** + * Platform API key to authenticate with instead of a Cognito session. + * When set (or when `BGAGENT_API_KEY` is in the environment), requests carry + * the `X-API-Key` header and no `bgagent login` is required. Only the + * endpoints the key is scoped for will succeed (Phase 1: `webhooks:manage`). + */ + readonly apiKey?: string; +} + /** HTTP client for the Background Agent REST API. */ export class ApiClient { private baseUrl: string | undefined; + private readonly apiKey: string | undefined; + + constructor(options: ApiClientOptions = {}) { + this.apiKey = options.apiKey ?? process.env.BGAGENT_API_KEY ?? undefined; + } + private getBaseUrl(): string { if (!this.baseUrl) { const config = loadConfig(); @@ -70,7 +90,10 @@ export class ApiClient { headers?: Record, signal?: AbortSignal, ): Promise { - const token = await getAuthToken(); + // API-key mode skips Cognito entirely: no cached token, no refresh. + const authHeaders: Record = this.apiKey + ? { 'X-API-Key': this.apiKey } + : { Authorization: await getAuthToken() }; const url = `${this.getBaseUrl()}${path}`; debug(`${method} ${url}`); @@ -83,7 +106,7 @@ export class ApiClient { const res = await fetch(url, { method, headers: { - 'Authorization': token, + ...authHeaders, 'Content-Type': 'application/json', ...headers, }, @@ -432,6 +455,34 @@ export class ApiClient { return res.data; } + /** POST /api-keys — mint a new platform API key (Cognito-authenticated). */ + async createApiKey(req: CreateApiKeyRequest): Promise { + const res = await this.request>('POST', '/api-keys', req); + return res.data; + } + + /** GET /api-keys — list platform API keys. */ + async listApiKeys(opts?: { + includeRevoked?: boolean; + limit?: number; + nextToken?: string; + }): Promise> { + const params = new URLSearchParams(); + if (opts?.includeRevoked) params.set('include_revoked', 'true'); + if (opts?.limit) params.set('limit', String(opts.limit)); + if (opts?.nextToken) params.set('next_token', opts.nextToken); + + const qs = params.toString(); + const path = `/api-keys${qs ? `?${qs}` : ''}`; + return this.request>('GET', path); + } + + /** DELETE /api-keys/{key_id} — revoke a platform API key. */ + async revokeApiKey(keyId: string): Promise { + const res = await this.request>('DELETE', `/api-keys/${encodeURIComponent(keyId)}`); + return res.data; + } + /** POST /slack/link — link a Slack account using a verification code. */ async slackLink(code: string): Promise { const res = await this.request>('POST', '/slack/link', { code }); diff --git a/cli/src/bin/bgagent.ts b/cli/src/bin/bgagent.ts index 5faf408f..47a797cd 100644 --- a/cli/src/bin/bgagent.ts +++ b/cli/src/bin/bgagent.ts @@ -21,6 +21,7 @@ import { Command } from 'commander'; import { makeAdminCommand } from '../commands/admin'; +import { makeApiKeyCommand } from '../commands/api-key'; import { makeApproveCommand } from '../commands/approve'; import { makeCancelCommand } from '../commands/cancel'; import { makeConfigureCommand } from '../commands/configure'; @@ -87,6 +88,7 @@ program.addCommand(makeOpsCommand()); program.addCommand(makeWatchCommand()); program.addCommand(makeTraceCommand()); program.addCommand(makeWebhookCommand()); +program.addCommand(makeApiKeyCommand()); program.addCommand(makeAdminCommand()); // Execute the CLI only when run directly. Importing this module (e.g. diff --git a/cli/src/commands/api-key.ts b/cli/src/commands/api-key.ts new file mode 100644 index 00000000..64979440 --- /dev/null +++ b/cli/src/commands/api-key.ts @@ -0,0 +1,109 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Command } from 'commander'; +import { ApiClient } from '../api-client'; +import { formatApiKeyCreated, formatApiKeyDetail, formatApiKeyList, formatJson } from '../format'; +import { API_KEY_SCOPES, ApiKeyScope } from '../types'; + +/** + * Parse a comma-separated `--scopes` value into validated scope strings. + * Throws (via commander's InvalidArgumentError) on an unknown scope so the + * user sees the allowed set instead of a server 400. + */ +function parseScopes(value: string): ApiKeyScope[] { + const known = new Set(API_KEY_SCOPES); + const parts = value.split(',').map((s) => s.trim()).filter(Boolean); + const out: ApiKeyScope[] = []; + for (const p of parts) { + if (!known.has(p)) { + throw new Error(`Unknown scope "${p}". Allowed: ${API_KEY_SCOPES.join(', ')}.`); + } + out.push(p as ApiKeyScope); + } + return out; +} + +export function makeApiKeyCommand(): Command { + const apiKey = new Command('api-key') + .description('Manage platform API keys for headless / CI automation'); + + apiKey.addCommand( + new Command('create') + .description('Create a new platform API key (requires `bgagent login`)') + .requiredOption('--name ', 'Key name') + .option('--scopes ', 'Comma-separated scopes (default: webhooks:manage)', parseScopes) + .option('--expires-at ', 'Optional ISO-8601 expiry timestamp') + .option('--output ', 'Output format (text or json)', 'text') + .action(async (opts) => { + const client = new ApiClient(); + const result = await client.createApiKey({ + name: opts.name, + ...(opts.scopes && { scopes: opts.scopes }), + ...(opts.expiresAt && { expires_at: opts.expiresAt }), + }); + + console.log(opts.output === 'json' ? formatJson(result) : formatApiKeyCreated(result)); + }), + ); + + apiKey.addCommand( + new Command('list') + .description('List platform API keys') + .option('--include-revoked', 'Include revoked keys') + .option('--limit ', 'Max number of keys to return', parseInt) + .option('--output ', 'Output format (text or json)', 'text') + .action(async (opts) => { + const client = new ApiClient(); + const result = await client.listApiKeys({ + includeRevoked: opts.includeRevoked, + limit: opts.limit, + }); + + if (opts.output === 'json') { + console.log(formatJson(result)); + } else { + console.log(formatApiKeyList(result.data)); + if (result.pagination.has_more) { + console.log('\n(More results available)'); + } + } + }), + ); + + apiKey.addCommand( + new Command('revoke') + .description('Revoke a platform API key') + .argument('', 'API key ID') + .option('--output ', 'Output format (text or json)', 'text') + .action(async (keyId: string, opts) => { + const client = new ApiClient(); + const result = await client.revokeApiKey(keyId); + + if (opts.output === 'json') { + console.log(formatJson(result)); + } else { + console.log(formatApiKeyDetail(result)); + console.log(`\nAPI key ${result.key_id} revoked.`); + } + }), + ); + + return apiKey; +} diff --git a/cli/src/commands/webhook.ts b/cli/src/commands/webhook.ts index 96496a98..d655e496 100644 --- a/cli/src/commands/webhook.ts +++ b/cli/src/commands/webhook.ts @@ -39,9 +39,10 @@ export function makeWebhookCommand(): Command { new Command('create') .description('Create a new webhook') .requiredOption('--name ', 'Webhook name') + .option('--api-key ', 'Platform API key (skips Cognito; else uses BGAGENT_API_KEY or login)') .option('--output ', 'Output format (text or json)', 'text') .action(async (opts) => { - const client = new ApiClient(); + const client = new ApiClient({ apiKey: opts.apiKey }); const result = await client.createWebhook({ name: opts.name }); console.log(opts.output === 'json' ? formatJson(result) : formatWebhookCreated(result)); @@ -53,9 +54,10 @@ export function makeWebhookCommand(): Command { .description('List webhooks') .option('--include-revoked', 'Include revoked webhooks') .option('--limit ', 'Max number of webhooks to return', parseInt) + .option('--api-key ', 'Platform API key (skips Cognito; else uses BGAGENT_API_KEY or login)') .option('--output ', 'Output format (text or json)', 'text') .action(async (opts) => { - const client = new ApiClient(); + const client = new ApiClient({ apiKey: opts.apiKey }); const result = await client.listWebhooks({ includeRevoked: opts.includeRevoked, limit: opts.limit, @@ -177,9 +179,10 @@ export function makeWebhookCommand(): Command { new Command('revoke') .description('Revoke a webhook') .argument('', 'Webhook ID') + .option('--api-key ', 'Platform API key (skips Cognito; else uses BGAGENT_API_KEY or login)') .option('--output ', 'Output format (text or json)', 'text') .action(async (webhookId: string, opts) => { - const client = new ApiClient(); + const client = new ApiClient({ apiKey: opts.apiKey }); const result = await client.revokeWebhook(webhookId); if (opts.output === 'json') { diff --git a/cli/src/debug.ts b/cli/src/debug.ts index 7666baab..f5e527ca 100644 --- a/cli/src/debug.ts +++ b/cli/src/debug.ts @@ -38,12 +38,15 @@ export function debug(message: string): void { /** * Field names whose values must never appear in `--verbose` output. - * Webhook creation returns a one-time `secret`; OAuth flows carry + * Webhook creation returns a one-time `secret`; API key creation returns a + * one-time `key` (and requests carry it in `x-api-key`); OAuth flows carry * `access_token` / `refresh_token`. Verbose logs land in scrollback and * CI logs, which outlive the "shown once" guarantee of those values. */ const SENSITIVE_FIELDS = new Set([ 'secret', + 'key', + 'x-api-key', 'access_token', 'refresh_token', 'id_token', diff --git a/cli/src/format.ts b/cli/src/format.ts index 10162fa9..2605115b 100644 --- a/cli/src/format.ts +++ b/cli/src/format.ts @@ -17,7 +17,7 @@ * SOFTWARE. */ -import { CreateWebhookResponse, DEFAULT_CODING_WORKFLOW_ID, ReplayBundle, TaskDetail, TaskEvent, TaskSummary, TERMINAL_STATUSES, WebhookDetail } from './types'; +import { ApiKeyDetail, CreateApiKeyResponse, CreateWebhookResponse, DEFAULT_CODING_WORKFLOW_ID, ReplayBundle, TaskDetail, TaskEvent, TaskSummary, TERMINAL_STATUSES, WebhookDetail } from './types'; /** Decimal places when rendering USD cost figures (tenth of a cent matters for LLM spend). */ export const COST_USD_DECIMALS = 4; @@ -374,6 +374,58 @@ export function formatWebhookDetail(webhook: WebhookDetail): string { return lines.join('\n'); } +/** Format a newly created API key (includes the one-time key material). */ +export function formatApiKeyCreated(res: CreateApiKeyResponse): string { + return [ + `Key ID: ${res.key_id}`, + `Name: ${res.name}`, + `Scopes: ${res.scopes.join(', ')}`, + `Expires: ${res.expires_at ?? 'never'}`, + `Created: ${res.created_at}`, + '', + 'API key (store securely — shown only once):', + res.key, + '', + 'Use it with `--api-key ` or the BGAGENT_API_KEY environment variable.', + ].join('\n'); +} + +/** Format a list of ApiKeyDetail as an aligned table. */ +export function formatApiKeyList(keys: ApiKeyDetail[]): string { + if (keys.length === 0) { + return 'No API keys found.'; + } + + const headers = ['KEY ID', 'NAME', 'SCOPES', 'STATUS', 'EXPIRES', 'CREATED']; + const rows = keys.map(k => [ + k.key_id, + k.name, + k.scopes.join(','), + k.status, + k.expires_at ?? 'never', + k.created_at, + ]); + + return formatTable(headers, rows); +} + +/** Format an ApiKeyDetail as a key-value detail view. */ +export function formatApiKeyDetail(key: ApiKeyDetail): string { + const lines: string[] = [ + `Key ID: ${key.key_id}`, + `Name: ${key.name}`, + `Scopes: ${key.scopes.join(', ')}`, + `Status: ${key.status}`, + `Expires: ${key.expires_at ?? 'never'}`, + `Created: ${key.created_at}`, + `Updated: ${key.updated_at}`, + ]; + if (key.revoked_at) { + lines.push(`Revoked: ${key.revoked_at}`); + } + return lines.join('\n'); +} + /** Format data as JSON. */ export function formatJson(data: unknown): string { return JSON.stringify(data, null, 2); diff --git a/cli/src/types.ts b/cli/src/types.ts index 3ee7f91f..7f26e02b 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -412,6 +412,49 @@ export interface CreateWebhookResponse { readonly created_at: string; } +/** + * Scopes a platform API key may hold. Mirrors + * ``cdk/src/handlers/shared/types.ts`` per the CLI types-sync contract. + */ +export type ApiKeyScope = 'webhooks:manage' | 'webhooks:invoke' | 'tasks:read' | 'tasks:cancel'; + +/** Scopes recognized by the platform. Order is not significant. */ +export const API_KEY_SCOPES: readonly ApiKeyScope[] = [ + 'webhooks:manage', + 'webhooks:invoke', + 'tasks:read', + 'tasks:cancel', +]; + +/** Platform API key detail returned by API responses. */ +export interface ApiKeyDetail { + readonly key_id: string; + readonly name: string; + readonly scopes: readonly ApiKeyScope[]; + readonly status: 'active' | 'revoked'; + readonly created_at: string; + readonly updated_at: string; + readonly expires_at: string | null; + readonly revoked_at: string | null; +} + +/** Create API key request body for POST /v1/api-keys. */ +export interface CreateApiKeyRequest { + readonly name: string; + readonly scopes?: readonly ApiKeyScope[]; + readonly expires_at?: string; +} + +/** Create API key response — includes the key material (shown only once). */ +export interface CreateApiKeyResponse { + readonly key_id: string; + readonly name: string; + readonly key: string; + readonly scopes: readonly ApiKeyScope[]; + readonly expires_at: string | null; + readonly created_at: string; +} + /** Slack link response from POST /v1/slack/link. */ export interface SlackLinkResponse { readonly slack_team_id: string; diff --git a/cli/test/api-client.test.ts b/cli/test/api-client.test.ts index c922a559..54fb3043 100644 --- a/cli/test/api-client.test.ts +++ b/cli/test/api-client.test.ts @@ -18,6 +18,7 @@ */ import { ApiClient } from '../src/api-client'; +import { getAuthToken } from '../src/auth'; import { ApiError } from '../src/errors'; // Mock auth @@ -25,6 +26,8 @@ jest.mock('../src/auth', () => ({ getAuthToken: jest.fn().mockResolvedValue('mock-token'), })); +const mockGetAuthToken = getAuthToken as jest.Mock; + // Mock config jest.mock('../src/config', () => ({ loadConfig: jest.fn().mockReturnValue({ @@ -579,4 +582,44 @@ describe('ApiClient', () => { } }); }); + + describe('authentication mode', () => { + const okResponse = { ok: true, json: async () => ({ data: {} }) }; + + afterEach(() => { + delete process.env.BGAGENT_API_KEY; + }); + + test('default mode sends the Cognito Authorization header, no X-API-Key', async () => { + mockFetch.mockResolvedValue(okResponse); + await new ApiClient().listWebhooks(); + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers.Authorization).toBe('mock-token'); + expect(headers['X-API-Key']).toBeUndefined(); + }); + + test('constructor apiKey sends X-API-Key and skips Cognito', async () => { + mockFetch.mockResolvedValue(okResponse); + mockGetAuthToken.mockClear(); + await new ApiClient({ apiKey: 'bgak_k_s' }).listWebhooks(); + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers['X-API-Key']).toBe('bgak_k_s'); + expect(headers.Authorization).toBeUndefined(); + expect(mockGetAuthToken).not.toHaveBeenCalled(); + }); + + test('BGAGENT_API_KEY env is used when no explicit key is passed', async () => { + process.env.BGAGENT_API_KEY = 'bgak_env_key'; + mockFetch.mockResolvedValue(okResponse); + await new ApiClient().listWebhooks(); + expect(mockFetch.mock.calls[0][1].headers['X-API-Key']).toBe('bgak_env_key'); + }); + + test('explicit apiKey overrides the environment variable', async () => { + process.env.BGAGENT_API_KEY = 'bgak_env_key'; + mockFetch.mockResolvedValue(okResponse); + await new ApiClient({ apiKey: 'bgak_explicit' }).listWebhooks(); + expect(mockFetch.mock.calls[0][1].headers['X-API-Key']).toBe('bgak_explicit'); + }); + }); }); diff --git a/cli/test/commands/api-key.test.ts b/cli/test/commands/api-key.test.ts new file mode 100644 index 00000000..a0197794 --- /dev/null +++ b/cli/test/commands/api-key.test.ts @@ -0,0 +1,147 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { ApiClient } from '../../src/api-client'; +import { makeApiKeyCommand } from '../../src/commands/api-key'; + +jest.mock('../../src/api-client'); + +describe('api-key command', () => { + let consoleSpy: jest.SpiedFunction; + const mockCreate = jest.fn(); + const mockList = jest.fn(); + const mockRevoke = jest.fn(); + + beforeEach(() => { + consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + mockCreate.mockReset(); + mockList.mockReset(); + mockRevoke.mockReset(); + (ApiClient as jest.MockedClass).mockImplementation(() => ({ + createApiKey: mockCreate, + listApiKeys: mockList, + revokeApiKey: mockRevoke, + }) as unknown as ApiClient); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + describe('api-key create', () => { + test('creates a key with the default scope and shows the one-time key', async () => { + mockCreate.mockResolvedValue({ + key_id: 'k-1', + name: 'CI', + key: 'bgak_k-1_secret', // gitleaks:allow -- fabricated test fixture, not a real credential + scopes: ['webhooks:manage'], + expires_at: null, + created_at: '2026-01-01T00:00:00Z', + }); + + const cmd = makeApiKeyCommand(); + await cmd.parseAsync(['node', 'test', 'create', '--name', 'CI']); + + expect(mockCreate).toHaveBeenCalledWith({ name: 'CI' }); + const output = consoleSpy.mock.calls[0][0] as string; + expect(output).toContain('k-1'); + expect(output).toContain('bgak_k-1_secret'); + expect(output).toContain('shown only once'); + }); + + test('parses --scopes into an array', async () => { + mockCreate.mockResolvedValue({ + key_id: 'k-1', name: 'ops', key: 'bgak_x', scopes: ['tasks:read'], expires_at: null, created_at: 'x', + }); + + const cmd = makeApiKeyCommand(); + await cmd.parseAsync(['node', 'test', 'create', '--name', 'ops', '--scopes', 'webhooks:manage,tasks:read']); + + expect(mockCreate).toHaveBeenCalledWith({ + name: 'ops', + scopes: ['webhooks:manage', 'tasks:read'], + }); + }); + + test('rejects an unknown scope before calling the API', async () => { + const cmd = makeApiKeyCommand(); + await expect( + cmd.parseAsync(['node', 'test', 'create', '--name', 'x', '--scopes', 'bogus']), + ).rejects.toThrow(/Unknown scope/); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + test('passes --expires-at through', async () => { + mockCreate.mockResolvedValue({ + key_id: 'k', name: 'x', key: 'bgak_x', scopes: ['webhooks:manage'], expires_at: '2027-01-01T00:00:00Z', created_at: 'x', + }); + + const cmd = makeApiKeyCommand(); + await cmd.parseAsync(['node', 'test', 'create', '--name', 'x', '--expires-at', '2027-01-01T00:00:00Z']); + + expect(mockCreate).toHaveBeenCalledWith({ name: 'x', expires_at: '2027-01-01T00:00:00Z' }); + }); + }); + + describe('api-key list', () => { + test('lists keys and passes filters', async () => { + mockList.mockResolvedValue({ + data: [{ + key_id: 'k-1', + name: 'CI', + scopes: ['webhooks:manage'], + status: 'active', + expires_at: null, + created_at: 'c', + updated_at: 'u', + revoked_at: null, + }], + pagination: { next_token: null, has_more: false }, + }); + + const cmd = makeApiKeyCommand(); + await cmd.parseAsync(['node', 'test', 'list', '--include-revoked', '--limit', '5']); + + expect(mockList).toHaveBeenCalledWith({ includeRevoked: true, limit: 5 }); + expect(consoleSpy.mock.calls[0][0]).toContain('k-1'); + }); + }); + + describe('api-key revoke', () => { + test('revokes a key and confirms', async () => { + mockRevoke.mockResolvedValue({ + key_id: 'k-1', + name: 'CI', + scopes: ['webhooks:manage'], + status: 'revoked', + expires_at: null, + created_at: 'c', + updated_at: 'u', + revoked_at: 'r', + }); + + const cmd = makeApiKeyCommand(); + await cmd.parseAsync(['node', 'test', 'revoke', 'k-1']); + + expect(mockRevoke).toHaveBeenCalledWith('k-1'); + const calls = consoleSpy.mock.calls.map((c) => c[0] as string); + expect(calls.some((c) => c.includes('revoked'))).toBe(true); + }); + }); +}); diff --git a/docs/design/IDENTITY_AND_AUTH.md b/docs/design/IDENTITY_AND_AUTH.md index 2268a49d..1a4b2cb8 100644 --- a/docs/design/IDENTITY_AND_AUTH.md +++ b/docs/design/IDENTITY_AND_AUTH.md @@ -23,6 +23,7 @@ The before-state, verified at `origin/main`. Inbound is ABCA's own Cognito and H |---|---|---|---|---| | CLI / REST API | Cognito User Pool JWT, validated by API Gateway Cognito authorizer; handler reads `sub` as `user_id` | n/a | `using/Authentication.md` | per-user | | Webhooks (GitHub, Linear, Jira) | HMAC-SHA256, per-integration shared secret in Secrets Manager, Lambda REQUEST authorizer | n/a | `using/Authentication.md` | per-tenant | +| Webhook management (headless) | Platform API key **or** Cognito JWT — one unified Lambda REQUEST authorizer branches on `X-API-Key` vs `Authorization`. Keys are `bgak__`; the SHA-256 of the secret is stored in DynamoDB keyed by `key_id` (looked up per request), and the authorizer returns the key's owner `user_id` + scopes as context. Scoped to `webhooks:manage`. | n/a | `cdk/src/handlers/api-key-authorizer.ts` | per-user (key inherits creator's `user_id`) | | GitHub | n/a | Single shared PAT from Secrets Manager (`GITHUB_TOKEN_SECRET_ARN`), cached in `os.environ["GITHUB_TOKEN"]` | `agent/src/config.py:resolve_github_token()` | one token, all repos and users | | Linear | n/a | Per-workspace OAuth token (`actor=app`), DDB registry keyed by `linearWorkspaceId` → Secrets Manager, Lambda resolver refreshes within 60s of expiry | `cdk/src/handlers/shared/linear-oauth-resolver.ts` (594 LOC) + `agent/src/config.py:resolve_linear_api_token()` | per-workspace, not per-user | | Jira (PR #302) | n/a | Per-tenant OAuth 3LO → Secrets Manager, same pattern as Linear | `cdk/src/handlers/shared/jira-oauth-resolver.ts` + `cli/src/jira-oauth.ts` | per-tenant | diff --git a/docs/design/SECURITY.md b/docs/design/SECURITY.md index 01169917..79b1afac 100644 --- a/docs/design/SECURITY.md +++ b/docs/design/SECURITY.md @@ -27,14 +27,15 @@ The agent runs with full permissions inside the sandbox but cannot escape it. Th ## Authentication and authorization -Two authentication mechanisms protect the platform, matching the two input channels: +Three authentication mechanisms protect the platform, matching its input channels: | Channel | Mechanism | Details | |---------|-----------|---------| | CLI / REST API | Amazon Cognito JWT | Users authenticate and receive tokens. The input gateway verifies every request. | | Webhooks | HMAC-SHA256 | Per-integration shared secrets stored in Secrets Manager. Secrets are shown once at creation and scheduled for deletion with a 7-day recovery window on revocation. | +| Webhook management (headless) | Platform API key | Long-lived, scoped keys for CI / automation to manage webhooks without a Cognito user. The webhook management routes accept **either** a Cognito JWT **or** a `webhooks:manage` API key via one unified Lambda authorizer. Keys are `bgak__`; only the SHA-256 hash of the secret is stored (in DynamoDB, so the authorizer can look it up by `key_id` on every request), and the raw key is shown once at creation. Minting a key still requires an interactive Cognito user. | -**Authorization** is user-scoped: any authenticated user can submit tasks, but users can only view and cancel their own tasks (`user_id` enforcement). Webhook management enforces ownership with 404 (not 403) to avoid leaking webhook existence. +**Authorization** is user-scoped: any authenticated user can submit tasks, but users can only view and cancel their own tasks (`user_id` enforcement). Both webhook and API key management enforce ownership with 404 (not 403) to avoid leaking resource existence. A platform API key inherits its creator's `user_id`, so a webhook created via a key is attributed to that owner exactly as an interactive session would be. Key scopes gate which routes a key may call (Phase 1: `webhooks:manage`); reserved scopes (`tasks:read`, `tasks:cancel`) are validated but not yet wired to any route. **Agent credentials** - GitHub access currently uses a PAT stored in Secrets Manager. The orchestrator reads the secret at hydration time and passes it to the agent runtime. The model never receives the token in its context. Planned: replace the shared PAT with a GitHub App via AgentCore Identity Token Vault, providing per-task, repo-scoped, short-lived tokens (see [GitHub issues](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues), the [ADR-016](../decisions/ADR-016-pluggable-identity-and-auth.md) two-seam design, and the [IDENTITY_AND_AUTH.md](./IDENTITY_AND_AUTH.md) worked examples). diff --git a/docs/src/content/docs/architecture/Identity-and-auth.md b/docs/src/content/docs/architecture/Identity-and-auth.md index 29d521b4..ad88bfe8 100644 --- a/docs/src/content/docs/architecture/Identity-and-auth.md +++ b/docs/src/content/docs/architecture/Identity-and-auth.md @@ -27,6 +27,7 @@ The before-state, verified at `origin/main`. Inbound is ABCA's own Cognito and H |---|---|---|---|---| | CLI / REST API | Cognito User Pool JWT, validated by API Gateway Cognito authorizer; handler reads `sub` as `user_id` | n/a | `using/Authentication.md` | per-user | | Webhooks (GitHub, Linear, Jira) | HMAC-SHA256, per-integration shared secret in Secrets Manager, Lambda REQUEST authorizer | n/a | `using/Authentication.md` | per-tenant | +| Webhook management (headless) | Platform API key **or** Cognito JWT — one unified Lambda REQUEST authorizer branches on `X-API-Key` vs `Authorization`. Keys are `bgak__`; the SHA-256 of the secret is stored in DynamoDB keyed by `key_id` (looked up per request), and the authorizer returns the key's owner `user_id` + scopes as context. Scoped to `webhooks:manage`. | n/a | `cdk/src/handlers/api-key-authorizer.ts` | per-user (key inherits creator's `user_id`) | | GitHub | n/a | Single shared PAT from Secrets Manager (`GITHUB_TOKEN_SECRET_ARN`), cached in `os.environ["GITHUB_TOKEN"]` | `agent/src/config.py:resolve_github_token()` | one token, all repos and users | | Linear | n/a | Per-workspace OAuth token (`actor=app`), DDB registry keyed by `linearWorkspaceId` → Secrets Manager, Lambda resolver refreshes within 60s of expiry | `cdk/src/handlers/shared/linear-oauth-resolver.ts` (594 LOC) + `agent/src/config.py:resolve_linear_api_token()` | per-workspace, not per-user | | Jira (PR #302) | n/a | Per-tenant OAuth 3LO → Secrets Manager, same pattern as Linear | `cdk/src/handlers/shared/jira-oauth-resolver.ts` + `cli/src/jira-oauth.ts` | per-tenant | diff --git a/docs/src/content/docs/architecture/Security.md b/docs/src/content/docs/architecture/Security.md index ed54628d..78c54882 100644 --- a/docs/src/content/docs/architecture/Security.md +++ b/docs/src/content/docs/architecture/Security.md @@ -31,14 +31,15 @@ The agent runs with full permissions inside the sandbox but cannot escape it. Th ## Authentication and authorization -Two authentication mechanisms protect the platform, matching the two input channels: +Three authentication mechanisms protect the platform, matching its input channels: | Channel | Mechanism | Details | |---------|-----------|---------| | CLI / REST API | Amazon Cognito JWT | Users authenticate and receive tokens. The input gateway verifies every request. | | Webhooks | HMAC-SHA256 | Per-integration shared secrets stored in Secrets Manager. Secrets are shown once at creation and scheduled for deletion with a 7-day recovery window on revocation. | +| Webhook management (headless) | Platform API key | Long-lived, scoped keys for CI / automation to manage webhooks without a Cognito user. The webhook management routes accept **either** a Cognito JWT **or** a `webhooks:manage` API key via one unified Lambda authorizer. Keys are `bgak__`; only the SHA-256 hash of the secret is stored (in DynamoDB, so the authorizer can look it up by `key_id` on every request), and the raw key is shown once at creation. Minting a key still requires an interactive Cognito user. | -**Authorization** is user-scoped: any authenticated user can submit tasks, but users can only view and cancel their own tasks (`user_id` enforcement). Webhook management enforces ownership with 404 (not 403) to avoid leaking webhook existence. +**Authorization** is user-scoped: any authenticated user can submit tasks, but users can only view and cancel their own tasks (`user_id` enforcement). Both webhook and API key management enforce ownership with 404 (not 403) to avoid leaking resource existence. A platform API key inherits its creator's `user_id`, so a webhook created via a key is attributed to that owner exactly as an interactive session would be. Key scopes gate which routes a key may call (Phase 1: `webhooks:manage`); reserved scopes (`tasks:read`, `tasks:cancel`) are validated but not yet wired to any route. **Agent credentials** - GitHub access currently uses a PAT stored in Secrets Manager. The orchestrator reads the secret at hydration time and passes it to the agent runtime. The model never receives the token in its context. Planned: replace the shared PAT with a GitHub App via AgentCore Identity Token Vault, providing per-task, repo-scoped, short-lived tokens (see [GitHub issues](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues), the [ADR-016](/sample-autonomous-cloud-coding-agents/architecture/adr-016-pluggable-identity-and-auth) two-seam design, and the [IDENTITY_AND_AUTH.md](/sample-autonomous-cloud-coding-agents/architecture/identity-and-auth) worked examples). diff --git a/scripts/check-types-sync.ts b/scripts/check-types-sync.ts index 68ce017a..bf2f2555 100644 --- a/scripts/check-types-sync.ts +++ b/scripts/check-types-sync.ts @@ -96,6 +96,9 @@ const CDK_ONLY_ALLOWLIST = new Set([ 'NudgeRecord', 'EventRecord', 'WebhookRecord', + // Platform API key persistence shape — stores the secret hash and owner, + // neither of which the CLI ever sees (CLI uses ApiKeyDetail). + 'ApiKeyRecord', 'ApprovalDecisionRecordedEvent', // Server-side helper / internal contracts: 'TaskNotificationsConfig', diff --git a/yarn.lock b/yarn.lock index 04b6df25..484b6390 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3601,6 +3601,11 @@ aws-cdk@^2: resolved "https://registry.yarnpkg.com/aws-cdk/-/aws-cdk-2.1129.0.tgz#4f8d59001c8ca577ff21671601152460a396c652" integrity sha512-M0EWbjeKW+baUsirjtKuWh7w/9dPxF8taEi2hqo9ecCjGeQxV1dzyosJf+caR3Jh7dFyyT3J8zEtRb/cU971Rw== +aws-jwt-verify@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/aws-jwt-verify/-/aws-jwt-verify-5.2.1.tgz#d96e529a4a87ad96d39df09ab3d334b615fe40e0" + integrity sha512-J+buA4M+qvQDk58WXFBLkDodkEX3DDL1ac5XFQPW3opxAsaLXYu5hYnlSHsaBRDBUXBAn695kE/cw/mdyJKwJg== + axobject-query@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee"