diff --git a/.changeset/atomic-api-key-rotation.md b/.changeset/atomic-api-key-rotation.md new file mode 100644 index 000000000..fb929cc28 --- /dev/null +++ b/.changeset/atomic-api-key-rotation.md @@ -0,0 +1,11 @@ +--- +"@croco/auth-core": minor +"@croco/auth-drizzle": minor +"@croco/problems-core": patch +--- + +API key rotation now atomically revokes the old credential, replays the same protected replacement for idempotent retries, and durably recovers post-commit rotation events. + +Custom `ApiKeyStore` adapters must implement atomic rotation plus event claim, completion, and release operations. Callers must provide an idempotency key and configure an `ApiKeyRotationProtector`. + +Deploy the rotation schema first, pause rotation traffic, drain every instance using the legacy save-then-revoke path, deploy the new writers, and only then resume rotation. Mixed legacy and atomic rotation writers are not supported. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0157bdf27..3a2889951 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -327,6 +327,8 @@ jobs: - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' - 'turbo.json' + - 'packages/auth-core/**' + - 'packages/auth-drizzle/**' - 'packages/events-core/**' - 'packages/events-tx/**' - 'packages/framework-context/**' @@ -589,6 +591,13 @@ jobs: MIGRATION_POSTGRES_URL: postgresql://postgres:postgres@127.0.0.1:5432/croco_membership run: pnpm --filter @croco/migration-runner exec vitest run src/tests/MigrationStatusPostgres.spec.ts + - name: Verify API key rotation atomicity against PostgreSQL + env: + AUTH_POSTGRES_URL: postgresql://postgres:postgres@127.0.0.1:5432/croco_membership + run: | + pnpm build --filter=@croco/auth-drizzle... + pnpm --filter @croco/auth-drizzle exec vitest run src/tests/DrizzleApiKeyStore.postgres.spec.ts + - name: Verify typed TestKernel resources against real PostgreSQL and Redis run: | pnpm build --filter=@croco/testing-resources... diff --git a/docs/problem-code-registry.json b/docs/problem-code-registry.json index 04785dc9c..9eb14476a 100644 --- a/docs/problem-code-registry.json +++ b/docs/problem-code-registry.json @@ -1,6 +1,6 @@ { "version": "croco.problem-code-registry.v1", - "problemCount": 523, + "problemCount": 526, "problems": [ { "code": "ACCESS_DENIED", @@ -1352,6 +1352,66 @@ } ] }, + { + "code": "auth-core/api-key-rotation-conflict", + "category": "Conflict", + "status": 409, + "title": "Conflict", + "cookbookPath": "/reference/problem-recovery-cookbook/#auth-core-api-key-rotation-conflict", + "recovery": { + "cause": "The request conflicts with current state or an idempotency constraint.", + "userAction": "Refresh state, resolve the conflict, and retry with the updated intent.", + "operatorAction": "Inspect concurrent writes, idempotency keys, and uniqueness constraints.", + "retryability": "conditional", + "redactionPolicy": "safe-message", + "telemetry": { + "eventName": "croco.problem.warning", + "severity": "warning", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "lifecycle": { + "status": "active" + }, + "sources": [ + { + "file": "packages/auth-core/src/libs/problems/AuthProblems.ts", + "line": 75, + "column": 3, + "kind": "problem-class" + } + ] + }, + { + "code": "auth-core/api-key-rotation-protection-failed", + "category": "InternalServerError", + "status": 500, + "title": "Internal Server Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#auth-core-api-key-rotation-protection-failed", + "recovery": { + "cause": "Croco or an upstream dependency failed after accepting the request.", + "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.", + "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.", + "retryability": "conditional", + "redactionPolicy": "operator-only", + "telemetry": { + "eventName": "croco.problem.error", + "severity": "error", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "lifecycle": { + "status": "active" + }, + "sources": [ + { + "file": "packages/auth-core/src/libs/apikey/ApiKeyRotationProtector.ts", + "line": 28, + "column": 5, + "kind": "problem-constructor" + } + ] + }, { "code": "auth-core/auth-provider-unavailable", "category": "InternalServerError", @@ -1382,6 +1442,36 @@ } ] }, + { + "code": "auth-core/invalid-api-key-rotation-idempotency-key", + "category": "ValidationError", + "status": 422, + "title": "Validation Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#auth-core-invalid-api-key-rotation-idempotency-key", + "recovery": { + "cause": "The request or generated contract failed schema or semantic validation.", + "userAction": "Fix the invalid fields and retry with schema-conformant input.", + "operatorAction": "Inspect schema diagnostics, generated contracts, and validation metadata.", + "retryability": "not-retryable", + "redactionPolicy": "public", + "telemetry": { + "eventName": "croco.problem.info", + "severity": "info", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "lifecycle": { + "status": "active" + }, + "sources": [ + { + "file": "packages/auth-core/src/libs/problems/AuthProblems.ts", + "line": 83, + "column": 3, + "kind": "problem-class" + } + ] + }, { "code": "auth-core/invalid-permission-action", "category": "ValidationError", diff --git a/packages/auth-core/README.md b/packages/auth-core/README.md index 952c3f888..3a8b2cc10 100644 --- a/packages/auth-core/README.md +++ b/packages/auth-core/README.md @@ -11,7 +11,19 @@ pnpm add @croco/auth-core ## 사용법 ```ts -import { ApiKeyGenerator, ApiKeyHasher, ApiKeyManager } from "@croco/auth-core"; +import { + AesGcmApiKeyRotationProtector, + ApiKeyGenerator, + ApiKeyHasher, + ApiKeyManager, +} from "@croco/auth-core"; + +const rotationProtector = new AesGcmApiKeyRotationProtector({ + activeKeyId: "2026-07", + keys: { + "2026-07": rotationProtectionKey, + }, +}); const manager = new ApiKeyManager( apiKeyStore, @@ -19,6 +31,7 @@ const manager = new ApiKeyManager( new ApiKeyHasher(), eventBus, logger, + rotationProtector, ); const created = await manager.create({ @@ -28,8 +41,17 @@ const created = await manager.create({ }); const principal = await manager.verify(created.key); + +const rotated = await manager.rotate(created.id, { + idempotencyKey: "deploy-2026-07-30", +}); ``` +`rotate()`는 호출자가 제공한 멱등성 키로 하나의 논리적 회전을 식별합니다. 저장소는 기존 키 폐기와 대체 키 저장을 +원자적으로 처리해야 하며, 성공 응답이나 이벤트 발행이 유실되면 같은 멱등성 키로 재시도해 동일한 대체 키를 복구합니다. +복구 자료는 AES-256-GCM으로 보호되며, 회전 레코드가 재생 가능한 동안 해당 레코드를 암호화한 이전 보호 키도 설정에 +유지해야 합니다. + ```ts import { RequirePermission, RbacEngine, RoleRegistry } from "@croco/auth-core"; @@ -52,6 +74,7 @@ class ProjectController { - `ApiKeyManager`, API 키 생성, 검증, 폐기, 회전을 담당합니다. - `ApiKeyGenerator`, 안전한 API 키를 생성하고 파싱합니다. - `ApiKeyHasher`, API 키 해시와 검증을 담당합니다. +- `AesGcmApiKeyRotationProtector`, 재시도 가능한 회전 복구 자료를 보호합니다. - `RbacEngine`, 사용자와 역할 기반 권한 검사를 수행합니다. - `RoleRegistry`, 역할과 권한 집합을 관리합니다. - `AuthGuard`, `ApiKeyGuard`, `PermissionGuard`, `UnifiedAuthGuard`, 라우트 보호를 담당합니다. @@ -67,13 +90,14 @@ class ProjectController { - `AuthUser`, `Principal`, `ApiKeyPrincipal`, `UserPrincipal` - `AuthProvider`, `ApiKeyProvider`, `SessionProvider`, `TenantMappingProvider` -- `AuthRequest`, `ApiKey`, `CreateApiKeyOptions`, `CreateApiKeyResult` +- `AuthRequest`, `ApiKey`, `CreateApiKeyOptions`, `CreateApiKeyResult`, `RotateApiKeyOptions` ### 문제 타입 - `UnauthorizedProblem`, `ForbiddenProblem` - `AuthProviderUnavailableProblem` - `ApiKeyExpiredProblem`, `ApiKeyRevokedProblem`, `ApiKeyNotFoundProblem` +- `ApiKeyRotationConflictProblem`, `ApiKeyRotationProtectionProblem` - `InvalidPermissionFormatProblem`, `InvalidPermissionActionProblem` ## AuthGuard conformance diff --git a/packages/auth-core/src/index.ts b/packages/auth-core/src/index.ts index 86481fb20..d46168275 100644 --- a/packages/auth-core/src/index.ts +++ b/packages/auth-core/src/index.ts @@ -19,6 +19,20 @@ export { ApiKeyHasher } from "./libs/apikey/ApiKeyHasher"; */ export { ApiKeyManager } from "./libs/apikey/ApiKeyManager"; +/** + * API 키 회전 복구 자료를 보호하는 계약과 AES-GCM 구현입니다. + */ +export { + API_KEY_ROTATION_PROTECTOR_TOKEN, + AesGcmApiKeyRotationProtector, + ApiKeyRotationProtectionProblem, +} from "./libs/apikey/ApiKeyRotationProtector"; +export type { + AesGcmApiKeyRotationProtectorOptions, + ApiKeyRotationProtectionContext, + ApiKeyRotationProtector, +} from "./libs/apikey/ApiKeyRotationProtector"; + /** * API 키 저장소 토큰과 추상 저장소 계약입니다. */ @@ -92,8 +106,13 @@ export { AbstractRoleRegistry } from "./libs/interfaces/AbstractRoleRegistry"; export type { ApiKey, ApiKeyRateLimit, + ApiKeyRotation, + ApiKeyRotationInput, + ApiKeyRotationPhaseStatus, CreateApiKeyOptions, CreateApiKeyResult, + RotateApiKeyOptions, + RotateApiKeyResult, } from "./libs/interfaces/ApiKey"; /** @@ -152,7 +171,9 @@ export type { TenantMappingProvider } from "./libs/interfaces/TenantMapping"; export { ApiKeyCreationFailedProblem, ApiKeyExpiredProblem, + ApiKeyRotationConflictProblem, ApiKeyRevokedProblem, + InvalidApiKeyRotationIdempotencyKeyProblem, AuthProviderUnavailableProblem, ForbiddenProblem, InvalidPermissionActionProblem, diff --git a/packages/auth-core/src/libs/apikey/ApiKeyManager.ts b/packages/auth-core/src/libs/apikey/ApiKeyManager.ts index 2b16ef2bb..765117d17 100644 --- a/packages/auth-core/src/libs/apikey/ApiKeyManager.ts +++ b/packages/auth-core/src/libs/apikey/ApiKeyManager.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { EventBus } from "@croco/events-core"; import type { Logger } from "@croco/framework-logger"; import { recordError } from "@croco/telemetry-api"; @@ -12,15 +13,25 @@ import type { CreateApiKeyOptions, CreateApiKeyResult, RevokeApiKeyResult, + RotateApiKeyOptions, RotateApiKeyResult, } from "../interfaces/ApiKey"; import type { ApiKeyPrincipal } from "../interfaces/Principal"; -import { ForbiddenProblem } from "../problems/AuthProblems"; +import { + ForbiddenProblem, + InvalidApiKeyRotationIdempotencyKeyProblem, +} from "../problems/AuthProblems"; import { ApiKeyGenerator } from "./ApiKeyGenerator"; import { ApiKeyHasher } from "./ApiKeyHasher"; +import { + ApiKeyRotationProtectionProblem, + type ApiKeyRotationProtector, +} from "./ApiKeyRotationProtector"; import type { ApiKeyStore } from "./ApiKeyStore"; import { ApiKeyNotFoundProblem } from "./problems/ApiKeyNotFoundProblem"; +const ROTATION_EVENT_CLAIM_LEASE_MS = 5 * 60 * 1000; + export class ApiKeyManager { constructor( private readonly store: ApiKeyStore, @@ -28,6 +39,7 @@ export class ApiKeyManager { private readonly hasher: ApiKeyHasher = new ApiKeyHasher(), private readonly eventBus?: EventBus, private readonly logger?: Logger, + private readonly rotationProtector?: ApiKeyRotationProtector, ) {} private async runSideEffect(effectName: string, effect: Promise): Promise { @@ -156,11 +168,18 @@ export class ApiKeyManager { return degraded ? { degraded: true } : {}; } - async rotate(id: string): Promise { + async rotate(id: string, options: RotateApiKeyOptions): Promise { + if (options.idempotencyKey.trim().length === 0 || options.idempotencyKey.length > 255) { + throw new InvalidApiKeyRotationIdempotencyKeyProblem(); + } + const existingKey = await this.store.findById(id); if (!existingKey) { throw new ApiKeyNotFoundProblem(id); } + if (!this.rotationProtector) { + throw new ApiKeyRotationProtectionProblem("configure", "missing"); + } const { prefix = "sk", @@ -169,45 +188,107 @@ export class ApiKeyManager { fullKey, } = this.generator.generate(existingKey.prefix); const hash = this.hasher.hash(longToken); - - const newKey = await this.store.save({ - prefix, - shortToken, - hash, - name: existingKey.name, + const newKeyId = randomUUID(); + const protectionContext = { + oldKeyId: id, + newKeyId, tenantId: existingKey.tenantId, - permissions: existingKey.permissions, - createdBy: existingKey.createdBy, - expiresAt: existingKey.expiresAt, - revokedAt: null, - lastUsedAt: null, - rateLimit: existingKey.rateLimit, - allowedIps: existingKey.allowedIps, + idempotencyKey: options.idempotencyKey, + }; + const event = new ApiKeyRotatedEvent({ + oldKeyId: id, + newKeyId, + tenantId: existingKey.tenantId, + }); + const rotation = await this.store.rotate({ + oldKeyId: id, + replacement: { + id: newKeyId, + prefix, + shortToken, + hash, + }, + tenantId: existingKey.tenantId, + idempotencyKey: options.idempotencyKey, + recoveryCiphertext: this.rotationProtector.encrypt(fullKey, protectionContext), + eventStatus: this.eventBus ? "pending" : "completed", + eventClaimId: null, + eventClaimExpiresAt: null, + eventId: event.eventId, + eventOccurredAt: event.timestamp, }); - await this.store.revoke(id); + const recoveredKey = this.rotationProtector.decrypt(rotation.recoveryCiphertext, { + oldKeyId: rotation.oldKeyId, + newKeyId: rotation.replacement.id, + tenantId: rotation.tenantId, + idempotencyKey: rotation.idempotencyKey, + }); - const degraded = this.eventBus - ? await this.runSideEffect( - "ApiKeyRotatedEvent publish failed", - this.eventBus.publish( - new ApiKeyRotatedEvent({ - oldKeyId: id, - newKeyId: newKey.id, - tenantId: existingKey.tenantId, - }), - ), - ) - : false; + const degraded = this.eventBus ? await this.publishRotationEvent(rotation) : false; return { - key: fullKey, - id: newKey.id, - keyStart: `${prefix}_${shortToken.slice(0, 8)}...`, + key: recoveredKey, + id: rotation.replacement.id, + keyStart: `${rotation.replacement.prefix}_${rotation.replacement.shortToken.slice(0, 8)}...`, degraded: degraded || undefined, }; } + private async publishRotationEvent( + rotation: Awaited>, + ): Promise { + if (!this.eventBus || rotation.eventStatus === "completed") { + return false; + } + + const claimId = randomUUID(); + const claimed = await this.store.claimRotationEvent( + rotation.oldKeyId, + rotation.idempotencyKey, + claimId, + new Date(Date.now() + ROTATION_EVENT_CLAIM_LEASE_MS), + ); + if (!claimed) { + return true; + } + + try { + const event = new ApiKeyRotatedEvent({ + oldKeyId: claimed.oldKeyId, + newKeyId: claimed.replacement.id, + tenantId: claimed.tenantId, + }); + const eventIdentity = event as unknown as { + eventId: string; + timestamp: Date; + }; + eventIdentity.eventId = claimed.eventId; + eventIdentity.timestamp = new Date(claimed.eventOccurredAt); + await this.eventBus.publish(event); + const completed = await this.store.completeRotationEvent( + claimed.oldKeyId, + claimed.idempotencyKey, + claimId, + ); + return completed?.eventStatus !== "completed"; + } catch (error: unknown) { + recordError(error); + this.logger?.warn("ApiKeyRotatedEvent publish failed", { + error: error instanceof Error ? error.message : String(error), + }); + try { + await this.store.releaseRotationEvent(claimed.oldKeyId, claimed.idempotencyKey, claimId); + } catch (releaseError: unknown) { + recordError(releaseError); + this.logger?.warn("ApiKeyRotatedEvent claim release failed", { + error: releaseError instanceof Error ? releaseError.message : String(releaseError), + }); + } + return true; + } + } + async list(tenantId: string): Promise[]> { const keys = await this.store.listByTenant(tenantId); return keys.map(({ hash: _hash, ...rest }) => rest); diff --git a/packages/auth-core/src/libs/apikey/ApiKeyRotationProtector.ts b/packages/auth-core/src/libs/apikey/ApiKeyRotationProtector.ts new file mode 100644 index 000000000..16f68c479 --- /dev/null +++ b/packages/auth-core/src/libs/apikey/ApiKeyRotationProtector.ts @@ -0,0 +1,131 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { Token } from "@croco/framework-context"; +import { Problem, ProblemCategory } from "@croco/problems-core"; + +export interface ApiKeyRotationProtector { + encrypt(rawKey: string, context: ApiKeyRotationProtectionContext): string; + decrypt(ciphertext: string, context: ApiKeyRotationProtectionContext): string; +} + +export const API_KEY_ROTATION_PROTECTOR_TOKEN = new Token( + "API_KEY_ROTATION_PROTECTOR", +); + +export type AesGcmApiKeyRotationProtectorOptions = { + activeKeyId: string; + keys: Readonly>; +}; + +export type ApiKeyRotationProtectionContext = { + oldKeyId: string; + newKeyId: string; + tenantId: string; + idempotencyKey: string; +}; + +export class ApiKeyRotationProtectionProblem extends Problem { + constructor(operation: "configure" | "encrypt" | "decrypt", keyId: string) { + super( + "auth-core/api-key-rotation-protection-failed", + ProblemCategory.InternalServerError, + "API key rotation recovery material could not be protected", + { + extensions: { + operation, + keyId, + retryable: false, + }, + }, + ); + } +} + +/** + * Rotation-capable AES-256-GCM protection for replayable API key rotations. + * + * Ciphertexts carry the key ID used to encrypt them. Keep old protection keys configured + * for as long as rotation records encrypted with them must remain replayable. + */ +export class AesGcmApiKeyRotationProtector implements ApiKeyRotationProtector { + constructor(private readonly options: AesGcmApiKeyRotationProtectorOptions) { + this.requireKey(options.activeKeyId); + } + + encrypt(rawKey: string, context: ApiKeyRotationProtectionContext): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", this.requireKey(this.options.activeKeyId), iv); + cipher.setAAD(this.contextAad(context)); + const encrypted = Buffer.concat([cipher.update(rawKey, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return [ + "v1", + Buffer.from(this.options.activeKeyId, "utf8").toString("base64url"), + iv.toString("base64url"), + encrypted.toString("base64url"), + tag.toString("base64url"), + ].join("."); + } + + decrypt(ciphertext: string, context: ApiKeyRotationProtectionContext): string { + try { + const parts = ciphertext.split("."); + if (parts.length !== 5) { + throw new ApiKeyRotationProtectionProblem("decrypt", "unknown"); + } + + const [version, encodedKeyId, encodedIv, encodedValue, encodedTag] = parts; + if ( + version !== "v1" || + !encodedKeyId || + !encodedIv || + !encodedValue || + !encodedTag || + !/^[A-Za-z0-9_-]+$/.test(encodedKeyId) + ) { + throw new ApiKeyRotationProtectionProblem("decrypt", "unknown"); + } + + const keyId = Buffer.from(encodedKeyId, "base64url").toString("utf8"); + if (!keyId || Buffer.from(keyId, "utf8").toString("base64url") !== encodedKeyId) { + throw new ApiKeyRotationProtectionProblem("decrypt", "unknown"); + } + + const decipher = createDecipheriv( + "aes-256-gcm", + this.requireKey(keyId), + Buffer.from(encodedIv, "base64url"), + ); + decipher.setAAD(this.contextAad(context)); + decipher.setAuthTag(Buffer.from(encodedTag, "base64url")); + return Buffer.concat([ + decipher.update(Buffer.from(encodedValue, "base64url")), + decipher.final(), + ]).toString("utf8"); + } catch (error) { + if (error instanceof ApiKeyRotationProtectionProblem) { + throw error; + } + throw new ApiKeyRotationProtectionProblem("decrypt", "unknown"); + } + } + + private requireKey(keyId: string): Uint8Array { + const key = this.options.keys[keyId]; + if (!key || key.byteLength !== 32) { + throw new ApiKeyRotationProtectionProblem("configure", keyId); + } + return key; + } + + private contextAad(context: ApiKeyRotationProtectionContext): Buffer { + return Buffer.from( + JSON.stringify([ + context.oldKeyId, + context.newKeyId, + context.tenantId, + context.idempotencyKey, + ]), + "utf8", + ); + } +} diff --git a/packages/auth-core/src/libs/apikey/ApiKeyStore.ts b/packages/auth-core/src/libs/apikey/ApiKeyStore.ts index 3d5ea03af..19f02932e 100644 --- a/packages/auth-core/src/libs/apikey/ApiKeyStore.ts +++ b/packages/auth-core/src/libs/apikey/ApiKeyStore.ts @@ -1,10 +1,27 @@ import { Token } from "@croco/framework-context"; -import type { ApiKey } from "../interfaces/ApiKey"; +import type { ApiKey, ApiKeyRotation, ApiKeyRotationInput } from "../interfaces/ApiKey"; export abstract class ApiKeyStore { abstract findById(id: string): Promise; abstract findByShortToken(shortToken: string): Promise; abstract save(key: Omit): Promise; + abstract rotate(input: ApiKeyRotationInput): Promise; + abstract claimRotationEvent( + oldKeyId: string, + idempotencyKey: string, + claimId: string, + claimExpiresAt: Date, + ): Promise; + abstract completeRotationEvent( + oldKeyId: string, + idempotencyKey: string, + claimId: string, + ): Promise; + abstract releaseRotationEvent( + oldKeyId: string, + idempotencyKey: string, + claimId: string, + ): Promise; abstract updateLastUsed(id: string): Promise; abstract revoke(id: string): Promise; abstract listByTenant(tenantId: string): Promise; diff --git a/packages/auth-core/src/libs/interfaces/ApiKey.ts b/packages/auth-core/src/libs/interfaces/ApiKey.ts index dffbf9613..20ce243f4 100644 --- a/packages/auth-core/src/libs/interfaces/ApiKey.ts +++ b/packages/auth-core/src/libs/interfaces/ApiKey.ts @@ -45,6 +45,30 @@ export type RotateApiKeyResult = { degraded?: boolean; }; +export type RotateApiKeyOptions = { + idempotencyKey: string; +}; + +export type ApiKeyRotationPhaseStatus = "pending" | "processing" | "completed"; + +export type ApiKeyRotation = { + oldKeyId: string; + replacement: ApiKey; + tenantId: string; + idempotencyKey: string; + recoveryCiphertext: string; + eventStatus: ApiKeyRotationPhaseStatus; + eventClaimId: string | null; + eventClaimExpiresAt: Date | null; + eventId: string; + eventOccurredAt: Date; + createdAt: Date; +}; + +export type ApiKeyRotationInput = Omit & { + replacement: Pick; +}; + export type RevokeApiKeyResult = { degraded?: boolean; }; diff --git a/packages/auth-core/src/libs/problems/AuthProblems.ts b/packages/auth-core/src/libs/problems/AuthProblems.ts index f4e026364..75accd32c 100644 --- a/packages/auth-core/src/libs/problems/AuthProblems.ts +++ b/packages/auth-core/src/libs/problems/AuthProblems.ts @@ -70,3 +70,19 @@ export class ApiKeyCreationFailedProblem extends Problem { super(detail); } } + +export class ApiKeyRotationConflictProblem extends Problem { + readonly code = "auth-core/api-key-rotation-conflict"; + readonly category = ProblemCategory.Conflict; + constructor(detail = "API key rotation conflicts with an existing rotation") { + super(detail); + } +} + +export class InvalidApiKeyRotationIdempotencyKeyProblem extends Problem { + readonly code = "auth-core/invalid-api-key-rotation-idempotency-key"; + readonly category = ProblemCategory.ValidationError; + constructor() { + super("API key rotation idempotency key must contain between 1 and 255 characters"); + } +} diff --git a/packages/auth-core/src/tests/AesGcmApiKeyRotationProtector.spec.ts b/packages/auth-core/src/tests/AesGcmApiKeyRotationProtector.spec.ts new file mode 100644 index 000000000..2c599c778 --- /dev/null +++ b/packages/auth-core/src/tests/AesGcmApiKeyRotationProtector.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + AesGcmApiKeyRotationProtector, + ApiKeyRotationProtectionProblem, +} from "../libs/apikey/ApiKeyRotationProtector"; + +const CONTEXT = { + oldKeyId: "old-key", + newKeyId: "new-key", + tenantId: "tenant-1", + idempotencyKey: "rotation-1", +}; + +describe("AesGcmApiKeyRotationProtector", () => { + it("should reject protection keys that are not 32 bytes", () => { + expect( + () => + new AesGcmApiKeyRotationProtector({ + activeKeyId: "invalid", + keys: { invalid: new Uint8Array(31) }, + }), + ).toThrow(ApiKeyRotationProtectionProblem); + }); + + it("should reject malformed ciphertext", () => { + const protector = new AesGcmApiKeyRotationProtector({ + activeKeyId: "current", + keys: { current: new Uint8Array(32).fill(7) }, + }); + + expect(() => protector.decrypt("v1.a.b.c", CONTEXT)).toThrow(ApiKeyRotationProtectionProblem); + }); + + it("should reject ciphertext encrypted by a removed key", () => { + const oldProtector = new AesGcmApiKeyRotationProtector({ + activeKeyId: "previous", + keys: { previous: new Uint8Array(32).fill(3) }, + }); + const ciphertext = oldProtector.encrypt("sk_short_long-secret", CONTEXT); + const currentProtector = new AesGcmApiKeyRotationProtector({ + activeKeyId: "current", + keys: { current: new Uint8Array(32).fill(7) }, + }); + + expect(() => currentProtector.decrypt(ciphertext, CONTEXT)).toThrow( + ApiKeyRotationProtectionProblem, + ); + }); + + it("should recover protected API key material", () => { + const protector = new AesGcmApiKeyRotationProtector({ + activeKeyId: "current", + keys: { current: new Uint8Array(32).fill(7) }, + }); + + const ciphertext = protector.encrypt("sk_short_long-secret", CONTEXT); + + expect(ciphertext).not.toContain("long-secret"); + expect(protector.decrypt(ciphertext, CONTEXT)).toBe("sk_short_long-secret"); + }); + + it("should fail closed when ciphertext is moved to another rotation", () => { + const protector = new AesGcmApiKeyRotationProtector({ + activeKeyId: "current", + keys: { current: new Uint8Array(32).fill(7) }, + }); + const ciphertext = protector.encrypt("sk_short_long-secret", CONTEXT); + + expect(() => protector.decrypt(ciphertext, { ...CONTEXT, newKeyId: "other-key" })).toThrow( + ApiKeyRotationProtectionProblem, + ); + }); + + it("should decrypt records created with a retained previous key", () => { + const previous = new Uint8Array(32).fill(3); + const current = new Uint8Array(32).fill(7); + const oldProtector = new AesGcmApiKeyRotationProtector({ + activeKeyId: "previous", + keys: { previous }, + }); + const ciphertext = oldProtector.encrypt("sk_short_long-secret", CONTEXT); + const rotatedProtector = new AesGcmApiKeyRotationProtector({ + activeKeyId: "current", + keys: { current, previous }, + }); + + expect(rotatedProtector.decrypt(ciphertext, CONTEXT)).toBe("sk_short_long-secret"); + }); +}); diff --git a/packages/auth-core/src/tests/ApiKeyManager.spec.ts b/packages/auth-core/src/tests/ApiKeyManager.spec.ts index f4854a691..ecebddadd 100644 --- a/packages/auth-core/src/tests/ApiKeyManager.spec.ts +++ b/packages/auth-core/src/tests/ApiKeyManager.spec.ts @@ -4,14 +4,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ApiKeyGenerator } from "../libs/apikey/ApiKeyGenerator"; import { ApiKeyHasher } from "../libs/apikey/ApiKeyHasher"; import { ApiKeyManager } from "../libs/apikey/ApiKeyManager"; +import { AesGcmApiKeyRotationProtector } from "../libs/apikey/ApiKeyRotationProtector"; import { ApiKeyCreatedEvent, ApiKeyRevokedEvent, ApiKeyRotatedEvent, ApiKeyUsedEvent, } from "../libs/events/ApiKeyEvents"; -import type { ApiKey, CreateApiKeyOptions } from "../libs/interfaces/ApiKey"; -import { ForbiddenProblem } from "../libs/problems/AuthProblems"; +import type { + ApiKey, + ApiKeyRotation, + ApiKeyRotationInput, + CreateApiKeyOptions, +} from "../libs/interfaces/ApiKey"; +import { ApiKeyRotationConflictProblem, ForbiddenProblem } from "../libs/problems/AuthProblems"; type MockEventBus = { publish: ReturnType; @@ -26,9 +32,11 @@ describe("ApiKeyManager", () => { let mockEventBus!: MockEventBus; let generator!: ApiKeyGenerator; let hasher!: ApiKeyHasher; + let rotationProtector!: AesGcmApiKeyRotationProtector; function createMockStore() { const keys = new Map(); + const rotations = new Map(); let idCounter = 1; return { @@ -51,6 +59,88 @@ describe("ApiKeyManager", () => { keys.set(id, key); return key; }), + rotate: vi.fn(async (input: ApiKeyRotationInput) => { + const existingRotation = rotations.get(input.oldKeyId); + if (existingRotation) { + if ( + existingRotation.tenantId !== input.tenantId || + existingRotation.idempotencyKey !== input.idempotencyKey + ) { + throw new ApiKeyRotationConflictProblem(); + } + return existingRotation; + } + + const oldKey = keys.get(input.oldKeyId); + if (!oldKey || oldKey.revokedAt) { + throw new ApiKeyRotationConflictProblem(); + } + const replacement: ApiKey = { + ...oldKey, + ...input.replacement, + createdAt: new Date(), + revokedAt: null, + lastUsedAt: null, + }; + oldKey.revokedAt = new Date(); + keys.set(replacement.id, replacement); + const rotation: ApiKeyRotation = { + ...input, + replacement, + createdAt: new Date(), + }; + rotations.set(input.oldKeyId, rotation); + return rotation; + }), + claimRotationEvent: vi.fn( + async (oldKeyId: string, idempotencyKey: string, claimId: string, claimExpiresAt: Date) => { + const rotation = rotations.get(oldKeyId); + if ( + !rotation || + rotation.idempotencyKey !== idempotencyKey || + rotation.eventStatus === "completed" || + (rotation.eventStatus === "processing" && + rotation.eventClaimExpiresAt && + rotation.eventClaimExpiresAt > new Date()) + ) { + return null; + } + rotation.eventStatus = "processing"; + rotation.eventClaimId = claimId; + rotation.eventClaimExpiresAt = claimExpiresAt; + return rotation; + }, + ), + completeRotationEvent: vi.fn( + async (oldKeyId: string, idempotencyKey: string, claimId: string) => { + const rotation = rotations.get(oldKeyId); + if ( + !rotation || + rotation.idempotencyKey !== idempotencyKey || + rotation.eventClaimId !== claimId + ) { + return null; + } + rotation.eventStatus = "completed"; + rotation.eventClaimId = null; + rotation.eventClaimExpiresAt = null; + return rotation; + }, + ), + releaseRotationEvent: vi.fn( + async (oldKeyId: string, idempotencyKey: string, claimId: string) => { + const rotation = rotations.get(oldKeyId); + if ( + rotation && + rotation.idempotencyKey === idempotencyKey && + rotation.eventClaimId === claimId + ) { + rotation.eventStatus = "pending"; + rotation.eventClaimId = null; + rotation.eventClaimExpiresAt = null; + } + }, + ), updateLastUsed: vi.fn(async (id: string) => { const key = keys.get(id); if (key) { @@ -87,7 +177,18 @@ describe("ApiKeyManager", () => { mockEventBus = createMockEventBus(); generator = new ApiKeyGenerator(); hasher = new ApiKeyHasher(); - manager = new ApiKeyManager(mockStore, generator, hasher, mockEventBus as unknown as EventBus); + rotationProtector = new AesGcmApiKeyRotationProtector({ + activeKeyId: "test", + keys: { test: new Uint8Array(32).fill(7) }, + }); + manager = new ApiKeyManager( + mockStore, + generator, + hasher, + mockEventBus as unknown as EventBus, + undefined, + rotationProtector, + ); }); describe("create", () => { @@ -422,35 +523,35 @@ describe("ApiKeyManager", () => { }); it("should create a new key and revoke the old one", async () => { - const result = await manager.rotate(originalKey.id); + const result = await manager.rotate(originalKey.id, { idempotencyKey: "rotation-1" }); expect(result.key).not.toBe(originalKey.key); expect(result.id).not.toBe(originalKey.id); expect(result.keyStart).toMatch(/^pk_[a-zA-Z0-9_~-]{8}\.\.\.$/); - expect(mockStore.save).toHaveBeenCalled(); - expect(mockStore.revoke).toHaveBeenCalledWith(originalKey.id); + expect(mockStore.rotate).toHaveBeenCalled(); }); it("should preserve original key properties", async () => { - await manager.rotate(originalKey.id); + await manager.rotate(originalKey.id, { idempotencyKey: "rotation-1" }); - const savedCall = mockStore.save.mock.calls[mockStore.save.mock.calls.length - 1][0]; - expect(savedCall.name).toBe("Production Key"); - expect(savedCall.tenantId).toBe("tenant_123"); - expect(savedCall.permissions).toEqual(["read:users", "write:users"]); - expect(savedCall.prefix).toBe("pk"); - expect(savedCall.rateLimit).toEqual({ limit: 1000, duration: 60 }); + const rotationCall = mockStore.rotate.mock.calls[0][0]; + const replacement = await mockStore.findById(rotationCall.replacement.id); + expect(replacement?.name).toBe("Production Key"); + expect(replacement?.tenantId).toBe("tenant_123"); + expect(replacement?.permissions).toEqual(["read:users", "write:users"]); + expect(replacement?.prefix).toBe("pk"); + expect(replacement?.rateLimit).toEqual({ limit: 1000, duration: 60 }); }); it("should throw error for non-existent key", async () => { - await expect(manager.rotate("nonexistent_id")).rejects.toThrow( - "API Key with id 'nonexistent_id' not found", - ); + await expect( + manager.rotate("nonexistent_id", { idempotencyKey: "rotation-missing" }), + ).rejects.toThrow("API Key with id 'nonexistent_id' not found"); }); it("should publish ApiKeyRotatedEvent on success", async () => { - const result = await manager.rotate(originalKey.id); + const result = await manager.rotate(originalKey.id, { idempotencyKey: "rotation-event" }); expect(mockEventBus.publish).toHaveBeenCalled(); const publishedEvent = mockEventBus.publish.mock.calls[1][0]; @@ -461,7 +562,14 @@ describe("ApiKeyManager", () => { }); it("should work without EventBus", async () => { - const managerWithoutBus = new ApiKeyManager(mockStore, generator, hasher); + const managerWithoutBus = new ApiKeyManager( + mockStore, + generator, + hasher, + undefined, + undefined, + rotationProtector, + ); const options: CreateApiKeyOptions = { name: "Production Key", tenantId: "tenant_123", @@ -470,7 +578,9 @@ describe("ApiKeyManager", () => { }; const createdKey = await managerWithoutBus.create(options); - const result = await managerWithoutBus.rotate(createdKey.id); + const result = await managerWithoutBus.rotate(createdKey.id, { + idempotencyKey: "rotation-no-bus", + }); expect(result.key).not.toBe(createdKey.key); expect(result.id).not.toBe(createdKey.id); @@ -482,7 +592,9 @@ describe("ApiKeyManager", () => { mockEventBus.publish.mockReset(); mockEventBus.publish.mockRejectedValueOnce(new Error("publish failed")); - const result = await manager.rotate(originalKey.id); + const result = await manager.rotate(originalKey.id, { + idempotencyKey: "rotation-publish-failure", + }); await Promise.resolve(); @@ -492,6 +604,100 @@ describe("ApiKeyManager", () => { recordErrorSpy.mockRestore(); }); + + it("should return the same replacement for an idempotent retry", async () => { + const first = await manager.rotate(originalKey.id, { + idempotencyKey: "rotation-replay", + }); + const second = await manager.rotate(originalKey.id, { + idempotencyKey: "rotation-replay", + }); + + expect(second).toEqual(first); + expect(mockStore._getKeys().size).toBe(2); + expect( + Array.from(mockStore._getKeys().values()).filter((key) => !key.revokedAt), + ).toHaveLength(1); + }); + + it("should recover a pending rotation event with its stable identity", async () => { + const recordErrorSpy = vi.spyOn(telemetry, "recordError").mockImplementation(() => {}); + mockEventBus.publish.mockReset(); + mockEventBus.publish + .mockRejectedValueOnce(new Error("publish failed")) + .mockResolvedValueOnce(undefined); + + const first = await manager.rotate(originalKey.id, { + idempotencyKey: "rotation-event-recovery", + }); + const firstEvent = mockEventBus.publish.mock.calls[0][0] as ApiKeyRotatedEvent; + const second = await manager.rotate(originalKey.id, { + idempotencyKey: "rotation-event-recovery", + }); + const secondEvent = mockEventBus.publish.mock.calls[1][0] as ApiKeyRotatedEvent; + + expect(first.degraded).toBe(true); + expect(second.degraded).toBeUndefined(); + expect(second.key).toBe(first.key); + expect(second.id).toBe(first.id); + expect(secondEvent.eventId).toBe(firstEvent.eventId); + expect(secondEvent.timestamp).toEqual(firstEvent.timestamp); + expect(mockStore.releaseRotationEvent).toHaveBeenCalledTimes(1); + expect(mockStore.completeRotationEvent).toHaveBeenCalledTimes(1); + + recordErrorSpy.mockRestore(); + }); + + it("should reject a second logical rotation without creating another active key", async () => { + await manager.rotate(originalKey.id, { idempotencyKey: "rotation-winner" }); + + await expect( + manager.rotate(originalKey.id, { idempotencyKey: "rotation-loser" }), + ).rejects.toThrow(ApiKeyRotationConflictProblem); + expect(mockStore._getKeys().size).toBe(2); + expect( + Array.from(mockStore._getKeys().values()).filter((key) => !key.revokedAt), + ).toHaveLength(1); + }); + + it("should persist only protected recovery material", async () => { + const result = await manager.rotate(originalKey.id, { + idempotencyKey: "rotation-protected", + }); + const input = mockStore.rotate.mock.calls[0][0]; + const parsed = generator.parse(result.key); + + expect(input.recoveryCiphertext).not.toContain(result.key); + expect(input.recoveryCiphertext).not.toContain(parsed?.longToken); + expect(JSON.stringify(input.replacement)).not.toContain(result.key); + expect(input.replacement.hash).toHaveLength(64); + }); + + it("should fail closed when rotation protection is not configured", async () => { + const managerWithoutProtector = new ApiKeyManager( + mockStore, + generator, + hasher, + mockEventBus as unknown as EventBus, + ); + + await expect( + managerWithoutProtector.rotate(originalKey.id, { + idempotencyKey: "rotation-without-protector", + }), + ).rejects.toThrow("API key rotation recovery material could not be protected"); + expect(mockStore.rotate).not.toHaveBeenCalled(); + }); + + it.each(["", " ", "x".repeat(256)])( + "should reject invalid rotation idempotency key %j", + async (idempotencyKey) => { + await expect(manager.rotate(originalKey.id, { idempotencyKey })).rejects.toThrow( + "API key rotation idempotency key must contain between 1 and 255 characters", + ); + expect(mockStore.rotate).not.toHaveBeenCalled(); + }, + ); }); describe("list", () => { diff --git a/packages/auth-core/src/tests/ApiKeySecurity.spec.ts b/packages/auth-core/src/tests/ApiKeySecurity.spec.ts index bb2f654cf..36806a34a 100644 --- a/packages/auth-core/src/tests/ApiKeySecurity.spec.ts +++ b/packages/auth-core/src/tests/ApiKeySecurity.spec.ts @@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ApiKeyGenerator } from "../libs/apikey/ApiKeyGenerator"; import { ApiKeyHasher } from "../libs/apikey/ApiKeyHasher"; import { ApiKeyManager } from "../libs/apikey/ApiKeyManager"; -import type { ApiKey, CreateApiKeyOptions } from "../libs/interfaces/ApiKey"; +import type { ApiKey, ApiKeyRotationInput, CreateApiKeyOptions } from "../libs/interfaces/ApiKey"; +import { ApiKeyRotationConflictProblem } from "../libs/problems/AuthProblems"; import type { EventBus } from "@croco/events-core"; describe("ApiKey Security", () => { @@ -35,6 +36,12 @@ describe("ApiKey Security", () => { keys.set(id, key); return key; }), + rotate: vi.fn(async (_input: ApiKeyRotationInput) => { + throw new ApiKeyRotationConflictProblem(); + }), + claimRotationEvent: vi.fn(async () => null), + completeRotationEvent: vi.fn(async () => null), + releaseRotationEvent: vi.fn(async () => {}), updateLastUsed: vi.fn(async (id: string) => { const key = keys.get(id); if (key) { @@ -185,7 +192,7 @@ describe("ApiKey Security", () => { it("rotate 실패 시 에러 메시지에 키 정보 포함하지 않음", async () => { try { - await manager.rotate("nonexistent_key_id"); + await manager.rotate("nonexistent_key_id", { idempotencyKey: "missing-rotation" }); expect.fail("에러가 발생해야 함"); } catch (error) { expect(error).toBeInstanceOf(Error); diff --git a/packages/auth-drizzle/README.md b/packages/auth-drizzle/README.md index 5fcd5d94a..57ba106e9 100644 --- a/packages/auth-drizzle/README.md +++ b/packages/auth-drizzle/README.md @@ -16,13 +16,16 @@ import { DrizzleRoleRegistry, DrizzleSessionProvider, DrizzleTenantMappingProvider, + addApiKeyRotations, + apiKeyRotations, apiKeys, sessions, tenantMappings, userRoles, } from "@croco/auth-drizzle"; -const apiKeyStore = new DrizzleApiKeyStore(db, { apiKeys }); +await addApiKeyRotations(db); +const apiKeyStore = new DrizzleApiKeyStore(db, { apiKeys, apiKeyRotations }); const sessionProvider = new DrizzleSessionProvider(db, { sessions }); const tenantMappingProvider = new DrizzleTenantMappingProvider(db, { tenantMappings }); const roleRegistry = new DrizzleRoleRegistry(db, { userRoles }); @@ -49,7 +52,7 @@ const activeSessions = await sessionProvider.listSessions({ userId: "user-1", st ### 저장소 -- `DrizzleApiKeyStore`, API 키 저장, 조회, 폐기, 삭제를 담당합니다. +- `DrizzleApiKeyStore`, API 키 저장, 조회, 원자적 회전, 폐기, 삭제를 담당합니다. - `DrizzleSessionProvider`, 세션 조회와 회수를 담당합니다. - `DrizzleTenantMappingProvider`, 외부 조직 ID와 테넌트 ID를 연결합니다. - `DrizzleRoleRegistry`, 역할 정의 등록과 사용자 역할 할당을 담당합니다. @@ -57,8 +60,17 @@ const activeSessions = await sessionProvider.listSessions({ userId: "user-1", st ### 스키마 - `apiKeys`, API 키 테이블입니다. +- `apiKeyRotations`, 멱등 회전, 보호된 복구 자료, 이벤트 전달 상태 테이블입니다. - `sessions`, 세션 테이블입니다. - `tenantMappings`, 외부 조직 매핑 테이블입니다. - `userRoles`, 사용자 역할 테이블입니다. 각 스키마는 PostgreSQL용 `pgTable` 정의이며, 인덱스와 유니크 제약을 함께 제공합니다. + +`addApiKeyRotations()`를 애플리케이션 스키마 마이그레이션에 포함해야 합니다. 회전은 내부 PostgreSQL 트랜잭션으로 +대체 키 저장과 기존 키 폐기를 함께 커밋하며, 회전 이벤트는 커밋 뒤 안정적인 이벤트 ID로 전달됩니다. + +기존 `save()` 후 `revoke()` 회전 경로를 사용하는 인스턴스와 새 원자적 회전 경로를 동시에 실행하면 안 됩니다. +배포할 때는 먼저 마이그레이션을 적용하고 API 키 회전을 중지한 뒤, 기존 회전 writer를 모두 drain하고 새 버전을 +배포한 다음 회전을 다시 활성화합니다. 이 schema-first 2단계 절차를 지키지 않는 mixed-version rollout은 지원하지 +않습니다. `delete()`는 키와 연결된 회전 복구 자료를 같은 트랜잭션에서 영구 삭제합니다. diff --git a/packages/auth-drizzle/package.json b/packages/auth-drizzle/package.json index abee777ea..21d62a02e 100644 --- a/packages/auth-drizzle/package.json +++ b/packages/auth-drizzle/package.json @@ -47,8 +47,10 @@ "@croco/tx-drizzle": "workspace:*", "@types/better-sqlite3": "7.6.13", "@types/node": "^22", + "@types/pg": "^8.15.6", "better-sqlite3": "^11.0.0", "drizzle-orm": "catalog:", + "pg": "^8.20.0", "tsup": "8.5.1", "typescript": "5.9.3", "vitest": "4.0.16" diff --git a/packages/auth-drizzle/src/index.ts b/packages/auth-drizzle/src/index.ts index e23e6039c..2b3c99e1b 100644 --- a/packages/auth-drizzle/src/index.ts +++ b/packages/auth-drizzle/src/index.ts @@ -17,4 +17,10 @@ export { DrizzleTenantMappingProvider } from "./libs/DrizzleTenantMappingProvide /** * 인증 저장소용 Drizzle 스키마입니다. */ -export { apiKeys, sessions, tenantMappings, userRoles } from "./schema/index.js"; +export { apiKeyRotations, apiKeys, sessions, tenantMappings, userRoles } from "./schema/index.js"; + +/** + * API 키 회전 의도 테이블 마이그레이션입니다. + */ +export { addApiKeyRotations, removeApiKeyRotations } from "./migrations/addApiKeyRotations.js"; +export type { ApiKeyRotationMigrationClient } from "./migrations/addApiKeyRotations.js"; diff --git a/packages/auth-drizzle/src/libs/DrizzleApiKeyStore.ts b/packages/auth-drizzle/src/libs/DrizzleApiKeyStore.ts index 090d2babb..a727e1069 100644 --- a/packages/auth-drizzle/src/libs/DrizzleApiKeyStore.ts +++ b/packages/auth-drizzle/src/libs/DrizzleApiKeyStore.ts @@ -1,25 +1,48 @@ -import type { ApiKey } from "@croco/auth-core"; -import { ApiKeyCreationFailedProblem, ApiKeyStore } from "@croco/auth-core"; +import type { ApiKey, ApiKeyRotation, ApiKeyRotationInput } from "@croco/auth-core"; +import { + ApiKeyCreationFailedProblem, + ApiKeyRotationConflictProblem, + ApiKeyStore, +} from "@croco/auth-core"; import type { SQLWrapper } from "drizzle-orm"; -import { eq } from "drizzle-orm"; -import type { apiKeys as apiKeysSchema } from "../schema"; +import { and, eq, isNull, lt, or } from "drizzle-orm"; +import type { apiKeyRotations as apiKeyRotationsSchema, apiKeys as apiKeysSchema } from "../schema"; +import { apiKeyRotations as defaultApiKeyRotations } from "../schema"; -interface DrizzleDb { - insert: (table: unknown) => { - values: (data: unknown) => { - returning: () => Promise; +interface ReturningQuery { + returning: () => Promise; +} + +interface InsertValuesQuery extends ReturningQuery { + onConflictDoNothing: (config?: { target?: unknown }) => ReturningQuery; +} + +interface SelectWhereQuery { + limit: (limit: number) => Promise; + for: (strength: "update") => Promise; +} + +interface DrizzleClient { + select: () => { + from: (table: unknown) => { + where: (condition: SQLWrapper) => SelectWhereQuery; }; }; + insert: (table: unknown) => { + values: (data: unknown) => InsertValuesQuery; + }; update: (table: unknown) => { set: (data: unknown) => { - where: (condition: SQLWrapper) => { - returning: () => Promise; - }; + where: (condition: SQLWrapper) => ReturningQuery & PromiseLike; }; }; delete: (table: unknown) => { where: (condition: SQLWrapper) => Promise; }; +} + +interface DrizzleDb extends DrizzleClient { + transaction: (callback: (tx: DrizzleClient) => Promise) => Promise; query: { apiKeys: { findFirst: (args: { where: SQLWrapper }) => Promise; @@ -45,6 +68,20 @@ interface ApiKeyRow { allowedIps: string[] | null; } +interface ApiKeyRotationRow { + oldKeyId: string; + newKeyId: string; + tenantId: string; + idempotencyKey: string; + recoveryCiphertext: string; + eventStatus: "pending" | "processing" | "completed"; + eventClaimId: string | null; + eventClaimExpiresAt: Date | null; + eventId: string; + eventOccurredAt: Date; + createdAt: Date; +} + function assertApiKeyRow(row: unknown): row is ApiKeyRow { if (!row || typeof row !== "object") { return false; @@ -62,6 +99,26 @@ function assertApiKeyRow(row: unknown): row is ApiKeyRow { ); } +function assertApiKeyRotationRow(row: unknown): row is ApiKeyRotationRow { + if (!row || typeof row !== "object") { + return false; + } + const record = row as Record; + return ( + typeof record.oldKeyId === "string" && + typeof record.newKeyId === "string" && + typeof record.tenantId === "string" && + typeof record.idempotencyKey === "string" && + typeof record.recoveryCiphertext === "string" && + (record.eventStatus === "pending" || + record.eventStatus === "processing" || + record.eventStatus === "completed") && + typeof record.eventId === "string" && + record.eventOccurredAt instanceof Date && + record.createdAt instanceof Date + ); +} + function mapRowToApiKey(row: ApiKeyRow): ApiKey { return { id: row.id, @@ -81,18 +138,37 @@ function mapRowToApiKey(row: ApiKeyRow): ApiKey { }; } +function requireCondition(condition: SQLWrapper | undefined): SQLWrapper { + if (!condition) { + throw new ApiKeyCreationFailedProblem("Failed to build API key persistence condition"); + } + return condition; +} + /** * API 키 저장소를 Drizzle 쿼리로 구현한 클래스입니다. */ export class DrizzleApiKeyStore extends ApiKeyStore { + private readonly schema: { + apiKeys: typeof apiKeysSchema; + apiKeyRotations: typeof apiKeyRotationsSchema; + }; + /** * Drizzle DB와 API 키 스키마를 받아 저장소를 초기화합니다. */ constructor( private readonly db: DrizzleDb, - private readonly schema: { apiKeys: typeof apiKeysSchema }, + schema: { + apiKeys: typeof apiKeysSchema; + apiKeyRotations?: typeof apiKeyRotationsSchema; + }, ) { super(); + this.schema = { + apiKeys: schema.apiKeys, + apiKeyRotations: schema.apiKeyRotations ?? defaultApiKeyRotations, + }; } /** @@ -154,6 +230,200 @@ export class DrizzleApiKeyStore extends ApiKeyStore { return mapRowToApiKey(row); } + /** + * 새 키 저장, 기존 키 폐기, 회전 복구 의도 기록을 한 트랜잭션으로 처리합니다. + */ + async rotate(input: ApiKeyRotationInput): Promise { + return this.db.transaction(async (tx) => { + const [oldKeyRow] = await tx + .select() + .from(this.schema.apiKeys) + .where(eq(this.schema.apiKeys.id, input.oldKeyId)) + .for("update"); + if (!assertApiKeyRow(oldKeyRow)) { + throw new ApiKeyRotationConflictProblem("API key is not active or cannot be rotated"); + } + + const existing = await this.findRotationWithClient( + tx, + input.oldKeyId, + input.tenantId, + input.idempotencyKey, + ); + if (existing) { + return existing; + } + + const oldKey = mapRowToApiKey(oldKeyRow); + if (!oldKey || oldKey.tenantId !== input.tenantId || oldKey.revokedAt) { + throw new ApiKeyRotationConflictProblem("API key is not active or cannot be rotated"); + } + + const [replacementRow] = await tx + .insert(this.schema.apiKeys) + .values({ + id: input.replacement.id, + prefix: oldKey.prefix, + shortToken: input.replacement.shortToken, + hash: input.replacement.hash, + permissions: oldKey.permissions, + name: oldKey.name, + tenantId: oldKey.tenantId, + createdBy: oldKey.createdBy, + expiresAt: oldKey.expiresAt, + revokedAt: null, + lastUsedAt: null, + rateLimit: oldKey.rateLimit ?? null, + allowedIps: oldKey.allowedIps ?? null, + }) + .returning(); + + if (!assertApiKeyRow(replacementRow)) { + throw new ApiKeyCreationFailedProblem(); + } + + const [insertedIntent] = await tx + .insert(this.schema.apiKeyRotations) + .values({ + oldKeyId: input.oldKeyId, + newKeyId: input.replacement.id, + tenantId: input.tenantId, + idempotencyKey: input.idempotencyKey, + recoveryCiphertext: input.recoveryCiphertext, + eventStatus: input.eventStatus, + eventClaimId: input.eventClaimId, + eventClaimExpiresAt: input.eventClaimExpiresAt, + eventId: input.eventId, + eventOccurredAt: input.eventOccurredAt, + }) + .onConflictDoNothing() + .returning(); + if (!assertApiKeyRotationRow(insertedIntent)) { + throw new ApiKeyRotationConflictProblem(); + } + + const revokedRows = await tx + .update(this.schema.apiKeys) + .set({ revokedAt: new Date() }) + .where( + requireCondition( + and(eq(this.schema.apiKeys.id, input.oldKeyId), isNull(this.schema.apiKeys.revokedAt)), + ), + ) + .returning(); + if (revokedRows.length !== 1) { + throw new ApiKeyRotationConflictProblem("API key was revoked concurrently"); + } + + return this.mapToRotation(insertedIntent, mapRowToApiKey(replacementRow)); + }); + } + + async claimRotationEvent( + oldKeyId: string, + idempotencyKey: string, + claimId: string, + claimExpiresAt: Date, + ): Promise { + return this.db.transaction(async (tx) => { + const [row] = await tx + .update(this.schema.apiKeyRotations) + .set({ + eventStatus: "processing", + eventClaimId: claimId, + eventClaimExpiresAt: claimExpiresAt, + }) + .where( + requireCondition( + and( + eq(this.schema.apiKeyRotations.oldKeyId, oldKeyId), + eq(this.schema.apiKeyRotations.idempotencyKey, idempotencyKey), + or( + eq(this.schema.apiKeyRotations.eventStatus, "pending"), + and( + eq(this.schema.apiKeyRotations.eventStatus, "processing"), + or( + isNull(this.schema.apiKeyRotations.eventClaimExpiresAt), + lt(this.schema.apiKeyRotations.eventClaimExpiresAt, new Date()), + ), + ), + ), + ), + ), + ) + .returning(); + + if (!assertApiKeyRotationRow(row)) { + return null; + } + const replacement = await this.findKeyWithClient(tx, row.newKeyId); + if (!replacement) { + throw new ApiKeyCreationFailedProblem("Rotation replacement key is missing"); + } + return this.mapToRotation(row, replacement); + }); + } + + async completeRotationEvent( + oldKeyId: string, + idempotencyKey: string, + claimId: string, + ): Promise { + return this.db.transaction(async (tx) => { + const [row] = await tx + .update(this.schema.apiKeyRotations) + .set({ + eventStatus: "completed", + eventClaimId: null, + eventClaimExpiresAt: null, + }) + .where( + requireCondition( + and( + eq(this.schema.apiKeyRotations.oldKeyId, oldKeyId), + eq(this.schema.apiKeyRotations.idempotencyKey, idempotencyKey), + eq(this.schema.apiKeyRotations.eventStatus, "processing"), + eq(this.schema.apiKeyRotations.eventClaimId, claimId), + ), + ), + ) + .returning(); + + if (!assertApiKeyRotationRow(row)) { + return null; + } + const replacement = await this.findKeyWithClient(tx, row.newKeyId); + if (!replacement) { + throw new ApiKeyCreationFailedProblem("Rotation replacement key is missing"); + } + return this.mapToRotation(row, replacement); + }); + } + + async releaseRotationEvent( + oldKeyId: string, + idempotencyKey: string, + claimId: string, + ): Promise { + await this.db + .update(this.schema.apiKeyRotations) + .set({ + eventStatus: "pending", + eventClaimId: null, + eventClaimExpiresAt: null, + }) + .where( + requireCondition( + and( + eq(this.schema.apiKeyRotations.oldKeyId, oldKeyId), + eq(this.schema.apiKeyRotations.idempotencyKey, idempotencyKey), + eq(this.schema.apiKeyRotations.eventStatus, "processing"), + eq(this.schema.apiKeyRotations.eventClaimId, claimId), + ), + ), + ); + } + /** * 마지막 사용 시각을 현재 시각으로 갱신합니다. */ @@ -196,6 +466,83 @@ export class DrizzleApiKeyStore extends ApiKeyStore { * API 키를 영구 삭제합니다. */ async delete(id: string): Promise { - await this.db.delete(this.schema.apiKeys).where(eq(this.schema.apiKeys.id, id)); + await this.db.transaction(async (tx) => { + await tx + .delete(this.schema.apiKeyRotations) + .where( + requireCondition( + or( + eq(this.schema.apiKeyRotations.oldKeyId, id), + eq(this.schema.apiKeyRotations.newKeyId, id), + ), + ), + ); + await tx.delete(this.schema.apiKeys).where(eq(this.schema.apiKeys.id, id)); + }); + } + + private async findKeyWithClient(client: DrizzleClient, id: string): Promise { + const [row] = await client + .select() + .from(this.schema.apiKeys) + .where(eq(this.schema.apiKeys.id, id)) + .limit(1); + return assertApiKeyRow(row) ? mapRowToApiKey(row) : null; + } + + private async findRotationWithClient( + client: DrizzleClient, + oldKeyId: string, + tenantId: string, + idempotencyKey: string, + ): Promise { + const [row] = await client + .select() + .from(this.schema.apiKeyRotations) + .where( + requireCondition( + or( + eq(this.schema.apiKeyRotations.oldKeyId, oldKeyId), + and( + eq(this.schema.apiKeyRotations.tenantId, tenantId), + eq(this.schema.apiKeyRotations.idempotencyKey, idempotencyKey), + ), + ), + ), + ) + .limit(1); + + if (!assertApiKeyRotationRow(row)) { + return null; + } + if ( + row.oldKeyId !== oldKeyId || + row.tenantId !== tenantId || + row.idempotencyKey !== idempotencyKey + ) { + throw new ApiKeyRotationConflictProblem(); + } + + const replacement = await this.findKeyWithClient(client, row.newKeyId); + if (!replacement) { + throw new ApiKeyCreationFailedProblem("API key rotation replacement is missing"); + } + return this.mapToRotation(row, replacement); + } + + private mapToRotation(row: ApiKeyRotationRow, replacement: ApiKey): ApiKeyRotation { + return { + oldKeyId: row.oldKeyId, + replacement, + tenantId: row.tenantId, + idempotencyKey: row.idempotencyKey, + recoveryCiphertext: row.recoveryCiphertext, + eventStatus: row.eventStatus, + eventClaimId: row.eventClaimId, + eventClaimExpiresAt: row.eventClaimExpiresAt, + eventId: row.eventId, + eventOccurredAt: row.eventOccurredAt, + createdAt: row.createdAt, + }; } } diff --git a/packages/auth-drizzle/src/migrations/addApiKeyRotations.ts b/packages/auth-drizzle/src/migrations/addApiKeyRotations.ts new file mode 100644 index 000000000..938fa3647 --- /dev/null +++ b/packages/auth-drizzle/src/migrations/addApiKeyRotations.ts @@ -0,0 +1,34 @@ +import { sql } from "drizzle-orm"; + +export type ApiKeyRotationMigrationClient = { + execute(query: unknown): Promise; +}; + +export async function addApiKeyRotations(db: ApiKeyRotationMigrationClient): Promise { + await db.execute(sql` + CREATE TABLE IF NOT EXISTS api_key_rotations ( + old_key_id uuid PRIMARY KEY REFERENCES api_keys(id) ON DELETE RESTRICT, + new_key_id uuid NOT NULL REFERENCES api_keys(id) ON DELETE RESTRICT, + tenant_id text NOT NULL, + idempotency_key text NOT NULL, + recovery_ciphertext text NOT NULL, + event_status text NOT NULL DEFAULT 'pending', + event_claim_id text, + event_claim_expires_at timestamp, + event_id text NOT NULL, + event_occurred_at timestamp NOT NULL, + created_at timestamp NOT NULL DEFAULT now(), + CONSTRAINT api_key_rotations_tenant_idempotency_unique + UNIQUE (tenant_id, idempotency_key), + CONSTRAINT api_key_rotations_new_key_unique UNIQUE (new_key_id) + ) + `); + await db.execute(sql` + CREATE INDEX IF NOT EXISTS api_key_rotations_event_status_idx + ON api_key_rotations (event_status, event_claim_expires_at) + `); +} + +export async function removeApiKeyRotations(db: ApiKeyRotationMigrationClient): Promise { + await db.execute(sql`DROP TABLE IF EXISTS api_key_rotations`); +} diff --git a/packages/auth-drizzle/src/schema/index.ts b/packages/auth-drizzle/src/schema/index.ts index 2b3974b55..65f04a086 100644 --- a/packages/auth-drizzle/src/schema/index.ts +++ b/packages/auth-drizzle/src/schema/index.ts @@ -1,4 +1,13 @@ -import { index, json, pgTable, text, timestamp, unique, uuid } from "drizzle-orm/pg-core"; +import { + index, + json, + pgTable, + text, + timestamp, + unique, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; /** * API 키를 저장하는 Drizzle 스키마입니다. @@ -28,6 +37,41 @@ export const apiKeys = pgTable( }), ); +/** + * API 키 회전의 멱등성, 복구 자료, 이벤트 전달 상태를 저장합니다. + */ +export const apiKeyRotations = pgTable( + "api_key_rotations", + { + oldKeyId: uuid("old_key_id") + .notNull() + .primaryKey() + .references(() => apiKeys.id, { onDelete: "restrict" }), + newKeyId: uuid("new_key_id") + .notNull() + .references(() => apiKeys.id, { onDelete: "restrict" }), + tenantId: text("tenant_id").notNull(), + idempotencyKey: text("idempotency_key").notNull(), + recoveryCiphertext: text("recovery_ciphertext").notNull(), + eventStatus: text("event_status", { enum: ["pending", "processing", "completed"] }) + .notNull() + .default("pending"), + eventClaimId: text("event_claim_id"), + eventClaimExpiresAt: timestamp("event_claim_expires_at"), + eventId: text("event_id").notNull(), + eventOccurredAt: timestamp("event_occurred_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + unique("api_key_rotations_new_key_unique").on(table.newKeyId), + uniqueIndex("api_key_rotations_tenant_idempotency_unique").on( + table.tenantId, + table.idempotencyKey, + ), + index("api_key_rotations_event_status_idx").on(table.eventStatus, table.eventClaimExpiresAt), + ], +); + /** * 세션 상태를 저장하는 Drizzle 스키마입니다. */ diff --git a/packages/auth-drizzle/src/tests/DrizzleApiKeyStore.postgres.spec.ts b/packages/auth-drizzle/src/tests/DrizzleApiKeyStore.postgres.spec.ts new file mode 100644 index 000000000..af0999ab7 --- /dev/null +++ b/packages/auth-drizzle/src/tests/DrizzleApiKeyStore.postgres.spec.ts @@ -0,0 +1,338 @@ +import { + AesGcmApiKeyRotationProtector, + ApiKeyGenerator, + ApiKeyHasher, + ApiKeyManager, + ApiKeyRotationConflictProblem, +} from "@croco/auth-core"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { DrizzleApiKeyStore } from "../libs/DrizzleApiKeyStore"; +import { addApiKeyRotations } from "../migrations/addApiKeyRotations"; +import { apiKeyRotations, apiKeys } from "../schema"; + +const connectionString = process.env.AUTH_POSTGRES_URL ?? ""; + +describe.skipIf(connectionString.length === 0)("DrizzleApiKeyStore PostgreSQL rotation", () => { + let pool!: Pool; + let store!: DrizzleApiKeyStore; + let protector!: AesGcmApiKeyRotationProtector; + + beforeAll(async () => { + pool = new Pool({ connectionString, max: 8 }); + await pool.query(` + CREATE TABLE IF NOT EXISTS api_keys ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + prefix text NOT NULL, + short_token text NOT NULL UNIQUE, + hash text NOT NULL, + permissions text[] NOT NULL DEFAULT '{}', + name text NOT NULL, + tenant_id text NOT NULL, + created_by text NOT NULL, + created_at timestamp NOT NULL DEFAULT now(), + expires_at timestamp, + revoked_at timestamp, + last_used_at timestamp, + rate_limit json, + allowed_ips text[] + ) + `); + const db = drizzle(pool, { schema: { apiKeys, apiKeyRotations } }); + await addApiKeyRotations(db); + store = new DrizzleApiKeyStore( + db as unknown as ConstructorParameters[0], + { apiKeys, apiKeyRotations }, + ); + protector = new AesGcmApiKeyRotationProtector({ + activeKeyId: "test", + keys: { test: new Uint8Array(32).fill(7) }, + }); + }); + + beforeEach(async () => { + await pool.query("DROP TRIGGER IF EXISTS reject_api_key_revoke ON api_keys"); + await pool.query("DROP FUNCTION IF EXISTS reject_api_key_revoke()"); + await pool.query("TRUNCATE TABLE api_key_rotations, api_keys"); + }); + + afterAll(async () => { + await pool.end(); + }); + + function createManager(eventBus?: ConstructorParameters[3]): ApiKeyManager { + return new ApiKeyManager( + store, + new ApiKeyGenerator(), + new ApiKeyHasher(), + eventBus, + undefined, + protector, + ); + } + + async function createOldKey(): Promise<{ id: string; key: string }> { + return createManager().create({ + name: "Production key", + tenantId: "tenant-1", + permissions: ["orders:read"], + prefix: "pk", + }); + } + + it("rotates with the immediate foreign keys emitted by the exported schema", async () => { + const constraints = await pool.query<{ condeferrable: boolean }>(` + SELECT condeferrable + FROM pg_constraint + WHERE conrelid = 'api_key_rotations'::regclass + AND contype = 'f' + ORDER BY conname + `); + expect(constraints.rows).toEqual([{ condeferrable: false }, { condeferrable: false }]); + + const oldKey = await createOldKey(); + const rotated = await createManager().rotate(oldKey.id, { + idempotencyKey: "immediate-foreign-keys", + }); + + expect(await store.findById(rotated.id)).toMatchObject({ id: rotated.id, revokedAt: null }); + }); + + it("returns one replacement to concurrent retries of the same logical rotation", async () => { + const oldKey = await createOldKey(); + const firstManager = createManager(); + const secondManager = createManager(); + + const [first, second] = await Promise.all([ + firstManager.rotate(oldKey.id, { idempotencyKey: "same-rotation" }), + secondManager.rotate(oldKey.id, { idempotencyKey: "same-rotation" }), + ]); + + expect(second).toEqual(first); + const state = await pool.query<{ + active_count: string; + total_count: string; + rotation_count: string; + }>(` + SELECT + count(*) FILTER (WHERE revoked_at IS NULL)::text AS active_count, + count(*)::text AS total_count, + (SELECT count(*)::text FROM api_key_rotations) AS rotation_count + FROM api_keys + `); + expect(state.rows[0]).toEqual({ + active_count: "1", + total_count: "2", + rotation_count: "1", + }); + }); + + it("allows only one of two competing logical rotations", async () => { + const oldKey = await createOldKey(); + + const results = await Promise.allSettled([ + createManager().rotate(oldKey.id, { idempotencyKey: "rotation-a" }), + createManager().rotate(oldKey.id, { idempotencyKey: "rotation-b" }), + ]); + + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + const rejected = results.find((result) => result.status === "rejected"); + expect(rejected).toMatchObject({ + status: "rejected", + reason: expect.any(ApiKeyRotationConflictProblem), + }); + const active = await pool.query<{ count: string }>( + "SELECT count(*)::text AS count FROM api_keys WHERE revoked_at IS NULL", + ); + expect(active.rows[0]?.count).toBe("1"); + }); + + it("rolls back the replacement when a tenant idempotency key conflicts", async () => { + const firstOldKey = await createOldKey(); + const secondOldKey = await createOldKey(); + + const results = await Promise.allSettled([ + createManager().rotate(firstOldKey.id, { idempotencyKey: "shared-rotation" }), + createManager().rotate(secondOldKey.id, { idempotencyKey: "shared-rotation" }), + ]); + + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + const state = await pool.query<{ active_count: string; total_count: string }>(` + SELECT + count(*) FILTER (WHERE revoked_at IS NULL)::text AS active_count, + count(*)::text AS total_count + FROM api_keys + `); + expect(state.rows[0]).toEqual({ active_count: "2", total_count: "3" }); + }); + + it("rolls back the replacement and intent when revocation fails", async () => { + const oldKey = await createOldKey(); + await pool.query(` + CREATE FUNCTION reject_api_key_revoke() RETURNS trigger AS $$ + BEGIN + IF OLD.revoked_at IS NULL AND NEW.revoked_at IS NOT NULL THEN + RAISE EXCEPTION 'injected revoke failure'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql + `); + await pool.query(` + CREATE TRIGGER reject_api_key_revoke + BEFORE UPDATE ON api_keys + FOR EACH ROW EXECUTE FUNCTION reject_api_key_revoke() + `); + + await expect( + createManager().rotate(oldKey.id, { idempotencyKey: "failed-rotation" }), + ).rejects.toMatchObject({ + cause: expect.objectContaining({ + message: expect.stringContaining("injected revoke failure"), + }), + }); + + const state = await pool.query<{ + active_count: string; + total_count: string; + rotation_count: string; + }>(` + SELECT + count(*) FILTER (WHERE revoked_at IS NULL)::text AS active_count, + count(*)::text AS total_count, + (SELECT count(*)::text FROM api_key_rotations) AS rotation_count + FROM api_keys + `); + expect(state.rows[0]).toEqual({ + active_count: "1", + total_count: "1", + rotation_count: "0", + }); + }); + + it("recovers post-commit event publication with the same event and credential", async () => { + const oldKey = await createOldKey(); + const publishedEvents: Array<{ eventId: string; timestamp: Date }> = []; + const observedStates: Array<{ old_revoked: boolean; new_active: boolean }> = []; + const eventBus = { + publish: vi.fn(async (event: { eventId: string; timestamp: Date }) => { + const state = await pool.query<{ old_revoked: boolean; new_active: boolean }>( + ` + SELECT + EXISTS ( + SELECT 1 FROM api_keys WHERE id = $1 AND revoked_at IS NOT NULL + ) AS old_revoked, + EXISTS ( + SELECT 1 FROM api_keys + WHERE id = (SELECT new_key_id FROM api_key_rotations WHERE old_key_id = $1) + AND revoked_at IS NULL + ) AS new_active + `, + [oldKey.id], + ); + if (state.rows[0]) { + observedStates.push(state.rows[0]); + } + publishedEvents.push({ eventId: event.eventId, timestamp: event.timestamp }); + if (publishedEvents.length === 1) { + throw new Error("injected publication failure"); + } + }), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + clear: vi.fn(), + } as unknown as NonNullable[3]>; + const manager = createManager(eventBus); + + const first = await manager.rotate(oldKey.id, { + idempotencyKey: "event-recovery", + }); + const second = await manager.rotate(oldKey.id, { + idempotencyKey: "event-recovery", + }); + + expect(first.degraded).toBe(true); + expect(observedStates).toEqual([ + { old_revoked: true, new_active: true }, + { old_revoked: true, new_active: true }, + ]); + expect(second.degraded).toBeUndefined(); + expect(second.key).toBe(first.key); + expect(second.id).toBe(first.id); + expect(publishedEvents[1]).toEqual(publishedEvents[0]); + const intent = await pool.query<{ + recovery_ciphertext: string; + event_status: string; + }>("SELECT recovery_ciphertext, event_status FROM api_key_rotations WHERE old_key_id = $1", [ + oldKey.id, + ]); + expect(intent.rows[0]?.event_status).toBe("completed"); + expect(intent.rows[0]?.recovery_ciphertext).not.toContain(first.key); + const parsed = new ApiKeyGenerator().parse(first.key); + expect(intent.rows[0]?.recovery_ciphertext).not.toContain(parsed?.longToken); + }); + + it.each(["old", "replacement"] as const)( + "purges recovery material when permanently deleting the %s key", + async (target) => { + const oldKey = await createOldKey(); + const rotated = await createManager().rotate(oldKey.id, { + idempotencyKey: `delete-${target}`, + }); + + await store.delete(target === "old" ? oldKey.id : rotated.id); + + const state = await pool.query<{ + old_exists: boolean; + replacement_exists: boolean; + rotation_count: string; + }>( + ` + SELECT + EXISTS (SELECT 1 FROM api_keys WHERE id = $1) AS old_exists, + EXISTS (SELECT 1 FROM api_keys WHERE id = $2) AS replacement_exists, + (SELECT count(*)::text FROM api_key_rotations) AS rotation_count + `, + [oldKey.id, rotated.id], + ); + expect(state.rows[0]).toEqual({ + old_exists: target !== "old", + replacement_exists: target !== "replacement", + rotation_count: "0", + }); + }, + ); + + it("documents the unsafe legacy mixed-writer state that deployment must drain", async () => { + const oldKey = await createOldKey(); + const old = await store.findById(oldKey.id); + expect(old).not.toBeNull(); + if (!old) { + return; + } + + await store.save({ + prefix: old.prefix, + shortToken: "legacyreplacement", + hash: "legacy-hash", + permissions: old.permissions, + name: old.name, + tenantId: old.tenantId, + createdBy: old.createdBy, + expiresAt: old.expiresAt, + revokedAt: null, + lastUsedAt: null, + rateLimit: old.rateLimit, + allowedIps: old.allowedIps, + }); + await createManager().rotate(oldKey.id, { idempotencyKey: "atomic-writer" }); + await store.revoke(oldKey.id); + + const active = await pool.query<{ count: string }>( + "SELECT count(*)::text AS count FROM api_keys WHERE revoked_at IS NULL", + ); + expect(active.rows[0]?.count).toBe("2"); + }); +}); diff --git a/packages/auth-drizzle/src/tests/DrizzleApiKeyStore.spec.ts b/packages/auth-drizzle/src/tests/DrizzleApiKeyStore.spec.ts index 8eac9a329..cf130c70e 100644 --- a/packages/auth-drizzle/src/tests/DrizzleApiKeyStore.spec.ts +++ b/packages/auth-drizzle/src/tests/DrizzleApiKeyStore.spec.ts @@ -8,6 +8,7 @@ describe("DrizzleApiKeyStore", () => { insert: ReturnType; update: ReturnType; delete: ReturnType; + transaction: ReturnType; query: { apiKeys: { findFirst: ReturnType; @@ -21,6 +22,7 @@ describe("DrizzleApiKeyStore", () => { insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + transaction: vi.fn(async (callback) => callback(mockDb)), query: { apiKeys: { findFirst: vi.fn(), @@ -295,7 +297,7 @@ describe("DrizzleApiKeyStore", () => { await store.delete("key-1"); - expect(mockDb.delete).toHaveBeenCalled(); + expect(mockDb.delete).toHaveBeenCalledTimes(2); }); }); }); diff --git a/packages/auth-drizzle/src/tests/DrizzleProviderConformance.spec.ts b/packages/auth-drizzle/src/tests/DrizzleProviderConformance.spec.ts index 6069c6ab4..da181e870 100644 --- a/packages/auth-drizzle/src/tests/DrizzleProviderConformance.spec.ts +++ b/packages/auth-drizzle/src/tests/DrizzleProviderConformance.spec.ts @@ -149,7 +149,7 @@ describe("auth-drizzle provider conformance", () => { findMany, }, }, - } as DrizzleApiKeyDb, + } as unknown as DrizzleApiKeyDb, { apiKeys }, ); @@ -184,7 +184,7 @@ describe("auth-drizzle provider conformance", () => { findMany: vi.fn(), }, }, - } as DrizzleApiKeyDb, + } as unknown as DrizzleApiKeyDb, { apiKeys }, ); @@ -214,7 +214,7 @@ describe("auth-drizzle provider conformance", () => { findMany: vi.fn(), }, }, - } as DrizzleApiKeyDb, + } as unknown as DrizzleApiKeyDb, { apiKeys }, ); diff --git a/packages/docs/src/content/docs/api/auth-core/src/classes/AesGcmApiKeyRotationProtector.md b/packages/docs/src/content/docs/api/auth-core/src/classes/AesGcmApiKeyRotationProtector.md new file mode 100644 index 000000000..731f0dba4 --- /dev/null +++ b/packages/docs/src/content/docs/api/auth-core/src/classes/AesGcmApiKeyRotationProtector.md @@ -0,0 +1,79 @@ +--- +editUrl: false +next: false +prev: false +title: "AesGcmApiKeyRotationProtector" +--- + +Rotation-capable AES-256-GCM protection for replayable API key rotations. + +Ciphertexts carry the key ID used to encrypt them. Keep old protection keys configured +for as long as rotation records encrypted with them must remain replayable. + +## Implements + +- [`ApiKeyRotationProtector`](/api/auth-core/src/interfaces/apikeyrotationprotector/) + +## Constructors + +### Constructor + +> **new AesGcmApiKeyRotationProtector**(`options`): `AesGcmApiKeyRotationProtector` + +#### Parameters + +##### options + +[`AesGcmApiKeyRotationProtectorOptions`](/api/auth-core/src/type-aliases/aesgcmapikeyrotationprotectoroptions/) + +#### Returns + +`AesGcmApiKeyRotationProtector` + +## Methods + +### decrypt() + +> **decrypt**(`ciphertext`, `context`): `string` + +#### Parameters + +##### ciphertext + +`string` + +##### context + +[`ApiKeyRotationProtectionContext`](/api/auth-core/src/type-aliases/apikeyrotationprotectioncontext/) + +#### Returns + +`string` + +#### Implementation of + +[`ApiKeyRotationProtector`](/api/auth-core/src/interfaces/apikeyrotationprotector/).[`decrypt`](/api/auth-core/src/interfaces/apikeyrotationprotector/#decrypt) + +--- + +### encrypt() + +> **encrypt**(`rawKey`, `context`): `string` + +#### Parameters + +##### rawKey + +`string` + +##### context + +[`ApiKeyRotationProtectionContext`](/api/auth-core/src/type-aliases/apikeyrotationprotectioncontext/) + +#### Returns + +`string` + +#### Implementation of + +[`ApiKeyRotationProtector`](/api/auth-core/src/interfaces/apikeyrotationprotector/).[`encrypt`](/api/auth-core/src/interfaces/apikeyrotationprotector/#encrypt) diff --git a/packages/docs/src/content/docs/api/auth-core/src/classes/ApiKeyManager.md b/packages/docs/src/content/docs/api/auth-core/src/classes/ApiKeyManager.md index 0eef425cb..99fa452d9 100644 --- a/packages/docs/src/content/docs/api/auth-core/src/classes/ApiKeyManager.md +++ b/packages/docs/src/content/docs/api/auth-core/src/classes/ApiKeyManager.md @@ -11,7 +11,7 @@ API 키 생성, 검증, 폐기, 회전을 담당하는 관리자입니다. ### Constructor -> **new ApiKeyManager**(`store`, `generator?`, `hasher?`, `eventBus?`, `logger?`): `ApiKeyManager` +> **new ApiKeyManager**(`store`, `generator?`, `hasher?`, `eventBus?`, `logger?`, `rotationProtector?`): `ApiKeyManager` #### Parameters @@ -35,6 +35,10 @@ API 키 생성, 검증, 폐기, 회전을 담당하는 관리자입니다. [`Logger`](/api/framework-logger/src/classes/logger/) +##### rotationProtector? + +[`ApiKeyRotationProtector`](/api/auth-core/src/interfaces/apikeyrotationprotector/) + #### Returns `ApiKeyManager` @@ -55,7 +59,7 @@ API 키 생성, 검증, 폐기, 회전을 담당하는 관리자입니다. `Promise`\<[`CreateApiKeyResult`](/api/auth-core/src/type-aliases/createapikeyresult/)\> -*** +--- ### list() @@ -71,7 +75,7 @@ API 키 생성, 검증, 폐기, 회전을 담당하는 관리자입니다. `Promise`\<`Omit`\<[`ApiKey`](/api/auth-core/src/type-aliases/apikey/), `"hash"`\>[]\> -*** +--- ### revoke() @@ -87,11 +91,11 @@ API 키 생성, 검증, 폐기, 회전을 담당하는 관리자입니다. `Promise`\<`RevokeApiKeyResult`\> -*** +--- ### rotate() -> **rotate**(`id`): `Promise`\<`RotateApiKeyResult`\> +> **rotate**(`id`, `options`): `Promise`\<[`RotateApiKeyResult`](/api/auth-core/src/type-aliases/rotateapikeyresult/)\> #### Parameters @@ -99,11 +103,15 @@ API 키 생성, 검증, 폐기, 회전을 담당하는 관리자입니다. `string` +##### options + +[`RotateApiKeyOptions`](/api/auth-core/src/type-aliases/rotateapikeyoptions/) + #### Returns -`Promise`\<`RotateApiKeyResult`\> +`Promise`\<[`RotateApiKeyResult`](/api/auth-core/src/type-aliases/rotateapikeyresult/)\> -*** +--- ### verify() diff --git a/packages/docs/src/content/docs/api/auth-core/src/classes/ApiKeyRotationConflictProblem.md b/packages/docs/src/content/docs/api/auth-core/src/classes/ApiKeyRotationConflictProblem.md new file mode 100644 index 000000000..40a7729b0 --- /dev/null +++ b/packages/docs/src/content/docs/api/auth-core/src/classes/ApiKeyRotationConflictProblem.md @@ -0,0 +1,294 @@ +--- +editUrl: false +next: false +prev: false +title: "ApiKeyRotationConflictProblem" +--- + +인증 도메인에서 사용하는 Problem 하위 타입들입니다. + +## Extends + +- [`Problem`](/api/problems-core/src/classes/problem/) + +## Constructors + +### Constructor + +> **new ApiKeyRotationConflictProblem**(`detail?`): `ApiKeyRotationConflictProblem` + +#### Parameters + +##### detail? + +`string` = `"API key rotation conflicts with an existing rotation"` + +#### Returns + +`ApiKeyRotationConflictProblem` + +#### Overrides + +`Problem.constructor` + +## Properties + +### category + +> `readonly` **category**: [`Conflict`](/api/problems-core/src/enumerations/problemcategory/#conflict) = `ProblemCategory.Conflict` + +#### Overrides + +[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category) + +--- + +### cause? + +> `readonly` `optional` **cause?**: `Error` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause) + +--- + +### code + +> `readonly` **code**: `"auth-core/api-key-rotation-conflict"` = `"auth-core/api-key-rotation-conflict"` + +#### Overrides + +[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code) + +--- + +### detail? + +> `readonly` `optional` **detail?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail) + +--- + +### extensions? + +> `readonly` `optional` **extensions?**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions) + +--- + +### instance? + +> `readonly` `optional` **instance?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance) + +--- + +### message + +> **message**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message) + +--- + +### name + +> **name**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name) + +--- + +### stack? + +> `optional` **stack?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack) + +--- + +### type + +> `readonly` **type**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type) + +--- + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit) + +## Accessors + +### status + +#### Get Signature + +> **get** **status**(): `number` + +##### Returns + +`number` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status) + +--- + +### title + +#### Get Signature + +> **get** **title**(): `string` + +##### Returns + +`string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title) + +## Methods + +### toJSON() + +> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Returns + +[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson) + +--- + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace) + +--- + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace) diff --git a/packages/docs/src/content/docs/api/auth-core/src/classes/ApiKeyRotationProtectionProblem.md b/packages/docs/src/content/docs/api/auth-core/src/classes/ApiKeyRotationProtectionProblem.md new file mode 100644 index 000000000..47ad97de2 --- /dev/null +++ b/packages/docs/src/content/docs/api/auth-core/src/classes/ApiKeyRotationProtectionProblem.md @@ -0,0 +1,298 @@ +--- +editUrl: false +next: false +prev: false +title: "ApiKeyRotationProtectionProblem" +--- + +API 키 회전 복구 자료를 보호하는 계약과 AES-GCM 구현입니다. + +## Extends + +- [`Problem`](/api/problems-core/src/classes/problem/) + +## Constructors + +### Constructor + +> **new ApiKeyRotationProtectionProblem**(`operation`, `keyId`): `ApiKeyRotationProtectionProblem` + +#### Parameters + +##### operation + +`"configure"` \| `"encrypt"` \| `"decrypt"` + +##### keyId + +`string` + +#### Returns + +`ApiKeyRotationProtectionProblem` + +#### Overrides + +`Problem.constructor` + +## Properties + +### category + +> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category) + +--- + +### cause? + +> `readonly` `optional` **cause?**: `Error` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause) + +--- + +### code + +> `readonly` **code**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code) + +--- + +### detail? + +> `readonly` `optional` **detail?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail) + +--- + +### extensions? + +> `readonly` `optional` **extensions?**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions) + +--- + +### instance? + +> `readonly` `optional` **instance?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance) + +--- + +### message + +> **message**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message) + +--- + +### name + +> **name**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name) + +--- + +### stack? + +> `optional` **stack?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack) + +--- + +### type + +> `readonly` **type**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type) + +--- + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit) + +## Accessors + +### status + +#### Get Signature + +> **get** **status**(): `number` + +##### Returns + +`number` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status) + +--- + +### title + +#### Get Signature + +> **get** **title**(): `string` + +##### Returns + +`string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title) + +## Methods + +### toJSON() + +> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Returns + +[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson) + +--- + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace) + +--- + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace) diff --git a/packages/docs/src/content/docs/api/auth-core/src/classes/ApiKeyStore.md b/packages/docs/src/content/docs/api/auth-core/src/classes/ApiKeyStore.md index d72f982b4..43db16ce2 100644 --- a/packages/docs/src/content/docs/api/auth-core/src/classes/ApiKeyStore.md +++ b/packages/docs/src/content/docs/api/auth-core/src/classes/ApiKeyStore.md @@ -23,6 +23,58 @@ API 키 저장소 토큰과 추상 저장소 계약입니다. ## Methods +### claimRotationEvent() + +> `abstract` **claimRotationEvent**(`oldKeyId`, `idempotencyKey`, `claimId`, `claimExpiresAt`): `Promise`\<[`ApiKeyRotation`](/api/auth-core/src/type-aliases/apikeyrotation/) \| `null`\> + +#### Parameters + +##### oldKeyId + +`string` + +##### idempotencyKey + +`string` + +##### claimId + +`string` + +##### claimExpiresAt + +`Date` + +#### Returns + +`Promise`\<[`ApiKeyRotation`](/api/auth-core/src/type-aliases/apikeyrotation/) \| `null`\> + +--- + +### completeRotationEvent() + +> `abstract` **completeRotationEvent**(`oldKeyId`, `idempotencyKey`, `claimId`): `Promise`\<[`ApiKeyRotation`](/api/auth-core/src/type-aliases/apikeyrotation/) \| `null`\> + +#### Parameters + +##### oldKeyId + +`string` + +##### idempotencyKey + +`string` + +##### claimId + +`string` + +#### Returns + +`Promise`\<[`ApiKeyRotation`](/api/auth-core/src/type-aliases/apikeyrotation/) \| `null`\> + +--- + ### delete() > `abstract` **delete**(`id`): `Promise`\<`void`\> @@ -37,7 +89,7 @@ API 키 저장소 토큰과 추상 저장소 계약입니다. `Promise`\<`void`\> -*** +--- ### findById() @@ -53,7 +105,7 @@ API 키 저장소 토큰과 추상 저장소 계약입니다. `Promise`\<[`ApiKey`](/api/auth-core/src/type-aliases/apikey/) \| `null`\> -*** +--- ### findByShortToken() @@ -69,7 +121,7 @@ API 키 저장소 토큰과 추상 저장소 계약입니다. `Promise`\<[`ApiKey`](/api/auth-core/src/type-aliases/apikey/) \| `null`\> -*** +--- ### listByTenant() @@ -85,7 +137,31 @@ API 키 저장소 토큰과 추상 저장소 계약입니다. `Promise`\<[`ApiKey`](/api/auth-core/src/type-aliases/apikey/)[]\> -*** +--- + +### releaseRotationEvent() + +> `abstract` **releaseRotationEvent**(`oldKeyId`, `idempotencyKey`, `claimId`): `Promise`\<`void`\> + +#### Parameters + +##### oldKeyId + +`string` + +##### idempotencyKey + +`string` + +##### claimId + +`string` + +#### Returns + +`Promise`\<`void`\> + +--- ### revoke() @@ -101,7 +177,23 @@ API 키 저장소 토큰과 추상 저장소 계약입니다. `Promise`\<`void`\> -*** +--- + +### rotate() + +> `abstract` **rotate**(`input`): `Promise`\<[`ApiKeyRotation`](/api/auth-core/src/type-aliases/apikeyrotation/)\> + +#### Parameters + +##### input + +[`ApiKeyRotationInput`](/api/auth-core/src/type-aliases/apikeyrotationinput/) + +#### Returns + +`Promise`\<[`ApiKeyRotation`](/api/auth-core/src/type-aliases/apikeyrotation/)\> + +--- ### save() @@ -117,7 +209,7 @@ API 키 저장소 토큰과 추상 저장소 계약입니다. `Promise`\<[`ApiKey`](/api/auth-core/src/type-aliases/apikey/)\> -*** +--- ### updateLastUsed() diff --git a/packages/docs/src/content/docs/api/auth-core/src/classes/InvalidApiKeyRotationIdempotencyKeyProblem.md b/packages/docs/src/content/docs/api/auth-core/src/classes/InvalidApiKeyRotationIdempotencyKeyProblem.md new file mode 100644 index 000000000..ffd66d091 --- /dev/null +++ b/packages/docs/src/content/docs/api/auth-core/src/classes/InvalidApiKeyRotationIdempotencyKeyProblem.md @@ -0,0 +1,288 @@ +--- +editUrl: false +next: false +prev: false +title: "InvalidApiKeyRotationIdempotencyKeyProblem" +--- + +인증 도메인에서 사용하는 Problem 하위 타입들입니다. + +## Extends + +- [`Problem`](/api/problems-core/src/classes/problem/) + +## Constructors + +### Constructor + +> **new InvalidApiKeyRotationIdempotencyKeyProblem**(): `InvalidApiKeyRotationIdempotencyKeyProblem` + +#### Returns + +`InvalidApiKeyRotationIdempotencyKeyProblem` + +#### Overrides + +`Problem.constructor` + +## Properties + +### category + +> `readonly` **category**: [`ValidationError`](/api/problems-core/src/enumerations/problemcategory/#validationerror) = `ProblemCategory.ValidationError` + +#### Overrides + +[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category) + +--- + +### cause? + +> `readonly` `optional` **cause?**: `Error` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause) + +--- + +### code + +> `readonly` **code**: `"auth-core/invalid-api-key-rotation-idempotency-key"` = `"auth-core/invalid-api-key-rotation-idempotency-key"` + +#### Overrides + +[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code) + +--- + +### detail? + +> `readonly` `optional` **detail?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail) + +--- + +### extensions? + +> `readonly` `optional` **extensions?**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions) + +--- + +### instance? + +> `readonly` `optional` **instance?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance) + +--- + +### message + +> **message**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message) + +--- + +### name + +> **name**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name) + +--- + +### stack? + +> `optional` **stack?**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack) + +--- + +### type + +> `readonly` **type**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type) + +--- + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit) + +## Accessors + +### status + +#### Get Signature + +> **get** **status**(): `number` + +##### Returns + +`number` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status) + +--- + +### title + +#### Get Signature + +> **get** **title**(): `string` + +##### Returns + +`string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title) + +## Methods + +### toJSON() + +> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Returns + +[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson) + +--- + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace) + +--- + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace) diff --git a/packages/docs/src/content/docs/api/auth-core/src/interfaces/ApiKeyRotationProtector.md b/packages/docs/src/content/docs/api/auth-core/src/interfaces/ApiKeyRotationProtector.md new file mode 100644 index 000000000..c7f46e3b2 --- /dev/null +++ b/packages/docs/src/content/docs/api/auth-core/src/interfaces/ApiKeyRotationProtector.md @@ -0,0 +1,46 @@ +--- +editUrl: false +next: false +prev: false +title: "ApiKeyRotationProtector" +--- + +## Methods + +### decrypt() + +> **decrypt**(`ciphertext`, `context`): `string` + +#### Parameters + +##### ciphertext + +`string` + +##### context + +[`ApiKeyRotationProtectionContext`](/api/auth-core/src/type-aliases/apikeyrotationprotectioncontext/) + +#### Returns + +`string` + +--- + +### encrypt() + +> **encrypt**(`rawKey`, `context`): `string` + +#### Parameters + +##### rawKey + +`string` + +##### context + +[`ApiKeyRotationProtectionContext`](/api/auth-core/src/type-aliases/apikeyrotationprotectioncontext/) + +#### Returns + +`string` diff --git a/packages/docs/src/content/docs/api/auth-core/src/type-aliases/AesGcmApiKeyRotationProtectorOptions.md b/packages/docs/src/content/docs/api/auth-core/src/type-aliases/AesGcmApiKeyRotationProtectorOptions.md new file mode 100644 index 000000000..9f8f269ff --- /dev/null +++ b/packages/docs/src/content/docs/api/auth-core/src/type-aliases/AesGcmApiKeyRotationProtectorOptions.md @@ -0,0 +1,20 @@ +--- +editUrl: false +next: false +prev: false +title: "AesGcmApiKeyRotationProtectorOptions" +--- + +> **AesGcmApiKeyRotationProtectorOptions** = `object` + +## Properties + +### activeKeyId + +> **activeKeyId**: `string` + +--- + +### keys + +> **keys**: `Readonly`\<`Record`\<`string`, `Uint8Array`\>\> diff --git a/packages/docs/src/content/docs/api/auth-core/src/type-aliases/ApiKeyRotation.md b/packages/docs/src/content/docs/api/auth-core/src/type-aliases/ApiKeyRotation.md new file mode 100644 index 000000000..2f09fd07c --- /dev/null +++ b/packages/docs/src/content/docs/api/auth-core/src/type-aliases/ApiKeyRotation.md @@ -0,0 +1,76 @@ +--- +editUrl: false +next: false +prev: false +title: "ApiKeyRotation" +--- + +> **ApiKeyRotation** = `object` + +API 키 도메인 모델과 생성 관련 타입입니다. + +## Properties + +### createdAt + +> **createdAt**: `Date` + +--- + +### eventClaimExpiresAt + +> **eventClaimExpiresAt**: `Date` \| `null` + +--- + +### eventClaimId + +> **eventClaimId**: `string` \| `null` + +--- + +### eventId + +> **eventId**: `string` + +--- + +### eventOccurredAt + +> **eventOccurredAt**: `Date` + +--- + +### eventStatus + +> **eventStatus**: [`ApiKeyRotationPhaseStatus`](/api/auth-core/src/type-aliases/apikeyrotationphasestatus/) + +--- + +### idempotencyKey + +> **idempotencyKey**: `string` + +--- + +### oldKeyId + +> **oldKeyId**: `string` + +--- + +### recoveryCiphertext + +> **recoveryCiphertext**: `string` + +--- + +### replacement + +> **replacement**: [`ApiKey`](/api/auth-core/src/type-aliases/apikey/) + +--- + +### tenantId + +> **tenantId**: `string` diff --git a/packages/docs/src/content/docs/api/auth-core/src/type-aliases/ApiKeyRotationInput.md b/packages/docs/src/content/docs/api/auth-core/src/type-aliases/ApiKeyRotationInput.md new file mode 100644 index 000000000..2a91fd5d2 --- /dev/null +++ b/packages/docs/src/content/docs/api/auth-core/src/type-aliases/ApiKeyRotationInput.md @@ -0,0 +1,16 @@ +--- +editUrl: false +next: false +prev: false +title: "ApiKeyRotationInput" +--- + +> **ApiKeyRotationInput** = `Omit`\<[`ApiKeyRotation`](/api/auth-core/src/type-aliases/apikeyrotation/), `"replacement"` \| `"createdAt"`\> & `object` + +API 키 도메인 모델과 생성 관련 타입입니다. + +## Type Declaration + +### replacement + +> **replacement**: `Pick`\<[`ApiKey`](/api/auth-core/src/type-aliases/apikey/), `"id"` \| `"prefix"` \| `"shortToken"` \| `"hash"`\> diff --git a/packages/docs/src/content/docs/api/auth-core/src/type-aliases/ApiKeyRotationPhaseStatus.md b/packages/docs/src/content/docs/api/auth-core/src/type-aliases/ApiKeyRotationPhaseStatus.md new file mode 100644 index 000000000..01468c292 --- /dev/null +++ b/packages/docs/src/content/docs/api/auth-core/src/type-aliases/ApiKeyRotationPhaseStatus.md @@ -0,0 +1,10 @@ +--- +editUrl: false +next: false +prev: false +title: "ApiKeyRotationPhaseStatus" +--- + +> **ApiKeyRotationPhaseStatus** = `"pending"` \| `"processing"` \| `"completed"` + +API 키 도메인 모델과 생성 관련 타입입니다. diff --git a/packages/docs/src/content/docs/api/auth-core/src/type-aliases/ApiKeyRotationProtectionContext.md b/packages/docs/src/content/docs/api/auth-core/src/type-aliases/ApiKeyRotationProtectionContext.md new file mode 100644 index 000000000..33a7e6427 --- /dev/null +++ b/packages/docs/src/content/docs/api/auth-core/src/type-aliases/ApiKeyRotationProtectionContext.md @@ -0,0 +1,32 @@ +--- +editUrl: false +next: false +prev: false +title: "ApiKeyRotationProtectionContext" +--- + +> **ApiKeyRotationProtectionContext** = `object` + +## Properties + +### idempotencyKey + +> **idempotencyKey**: `string` + +--- + +### newKeyId + +> **newKeyId**: `string` + +--- + +### oldKeyId + +> **oldKeyId**: `string` + +--- + +### tenantId + +> **tenantId**: `string` diff --git a/packages/docs/src/content/docs/api/auth-core/src/type-aliases/RotateApiKeyOptions.md b/packages/docs/src/content/docs/api/auth-core/src/type-aliases/RotateApiKeyOptions.md new file mode 100644 index 000000000..12e82fbf5 --- /dev/null +++ b/packages/docs/src/content/docs/api/auth-core/src/type-aliases/RotateApiKeyOptions.md @@ -0,0 +1,16 @@ +--- +editUrl: false +next: false +prev: false +title: "RotateApiKeyOptions" +--- + +> **RotateApiKeyOptions** = `object` + +API 키 도메인 모델과 생성 관련 타입입니다. + +## Properties + +### idempotencyKey + +> **idempotencyKey**: `string` diff --git a/packages/docs/src/content/docs/api/auth-core/src/type-aliases/RotateApiKeyResult.md b/packages/docs/src/content/docs/api/auth-core/src/type-aliases/RotateApiKeyResult.md new file mode 100644 index 000000000..91384f4e3 --- /dev/null +++ b/packages/docs/src/content/docs/api/auth-core/src/type-aliases/RotateApiKeyResult.md @@ -0,0 +1,34 @@ +--- +editUrl: false +next: false +prev: false +title: "RotateApiKeyResult" +--- + +> **RotateApiKeyResult** = `object` + +API 키 도메인 모델과 생성 관련 타입입니다. + +## Properties + +### degraded? + +> `optional` **degraded?**: `boolean` + +--- + +### id + +> **id**: `string` + +--- + +### key + +> **key**: `string` + +--- + +### keyStart + +> **keyStart**: `string` diff --git a/packages/docs/src/content/docs/api/auth-core/src/variables/API_KEY_ROTATION_PROTECTOR_TOKEN.md b/packages/docs/src/content/docs/api/auth-core/src/variables/API_KEY_ROTATION_PROTECTOR_TOKEN.md new file mode 100644 index 000000000..a32fb371e --- /dev/null +++ b/packages/docs/src/content/docs/api/auth-core/src/variables/API_KEY_ROTATION_PROTECTOR_TOKEN.md @@ -0,0 +1,10 @@ +--- +editUrl: false +next: false +prev: false +title: "API_KEY_ROTATION_PROTECTOR_TOKEN" +--- + +> `const` **API_KEY_ROTATION_PROTECTOR_TOKEN**: [`Token`](/api/framework-context/src/classes/token/)\<[`ApiKeyRotationProtector`](/api/auth-core/src/interfaces/apikeyrotationprotector/)\> + +API 키 회전 복구 자료를 보호하는 계약과 AES-GCM 구현입니다. diff --git a/packages/docs/src/content/docs/api/auth-drizzle/src/classes/DrizzleApiKeyStore.md b/packages/docs/src/content/docs/api/auth-drizzle/src/classes/DrizzleApiKeyStore.md index 214462273..97ac2bf41 100644 --- a/packages/docs/src/content/docs/api/auth-drizzle/src/classes/DrizzleApiKeyStore.md +++ b/packages/docs/src/content/docs/api/auth-drizzle/src/classes/DrizzleApiKeyStore.md @@ -27,6 +27,10 @@ Drizzle DB와 API 키 스키마를 받아 저장소를 초기화합니다. ##### schema +###### apiKeyRotations? + +`PgTableWithColumns`\<\{ `columns`: \{ `createdAt`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgTimestamp"`; `data`: `Date`; `dataType`: `"date"`; `driverParam`: `string`; `enumValues`: `undefined`; `generated`: `undefined`; `hasDefault`: `true`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"created_at"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `eventClaimExpiresAt`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgTimestamp"`; `data`: `Date`; `dataType`: `"date"`; `driverParam`: `string`; `enumValues`: `undefined`; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"event_claim_expires_at"`; `notNull`: `false`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `eventClaimId`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"event_claim_id"`; `notNull`: `false`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `eventId`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"event_id"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `eventOccurredAt`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgTimestamp"`; `data`: `Date`; `dataType`: `"date"`; `driverParam`: `string`; `enumValues`: `undefined`; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"event_occurred_at"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `eventStatus`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `"pending"` \| `"processing"` \| `"completed"`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`"pending"`, `"processing"`, `"completed"`\]; `generated`: `undefined`; `hasDefault`: `true`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"event_status"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `idempotencyKey`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"idempotency_key"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `newKeyId`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgUUID"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: `undefined`; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"new_key_id"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `oldKeyId`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgUUID"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: `undefined`; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `true`; `name`: `"old_key_id"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `recoveryCiphertext`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"recovery_ciphertext"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `tenantId`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"tenant_id"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; \}; `dialect`: `"pg"`; `name`: `"api_key_rotations"`; `schema`: `undefined`; \}\> + ###### apiKeys `PgTableWithColumns`\<\{ `columns`: \{ `allowedIps`: `PgColumn`\<\{ `baseColumn`: `Column`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"allowed_ips"`; `notNull`: `false`; `tableName`: `"api_keys"`; \}, \{ \}, \{ \}\>; `columnType`: `"PgArray"`; `data`: `string`[]; `dataType`: `"array"`; `driverParam`: `string` \| `string`[]; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"allowed_ips"`; `notNull`: `false`; `tableName`: `"api_keys"`; \}, \{ \}, \{ `baseBuilder`: `PgColumnBuilder`\<\{ `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `name`: `"allowed_ips"`; \}, \{ \}, \{ \}, `ColumnBuilderExtraConfig`\>; `size`: `undefined`; \}\>; `createdAt`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgTimestamp"`; `data`: `Date`; `dataType`: `"date"`; `driverParam`: `string`; `enumValues`: `undefined`; `generated`: `undefined`; `hasDefault`: `true`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"created_at"`; `notNull`: `true`; `tableName`: `"api_keys"`; \}, \{ \}, \{ \}\>; `createdBy`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"created_by"`; `notNull`: `true`; `tableName`: `"api_keys"`; \}, \{ \}, \{ \}\>; `expiresAt`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgTimestamp"`; `data`: `Date`; `dataType`: `"date"`; `driverParam`: `string`; `enumValues`: `undefined`; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"expires_at"`; `notNull`: `false`; `tableName`: `"api_keys"`; \}, \{ \}, \{ \}\>; `hash`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"hash"`; `notNull`: `true`; `tableName`: `"api_keys"`; \}, \{ \}, \{ \}\>; `id`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgUUID"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: `undefined`; `generated`: `undefined`; `hasDefault`: `true`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `true`; `name`: `"id"`; `notNull`: `true`; `tableName`: `"api_keys"`; \}, \{ \}, \{ \}\>; `lastUsedAt`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgTimestamp"`; `data`: `Date`; `dataType`: `"date"`; `driverParam`: `string`; `enumValues`: `undefined`; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"last_used_at"`; `notNull`: `false`; `tableName`: `"api_keys"`; \}, \{ \}, \{ \}\>; `name`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"name"`; `notNull`: `true`; `tableName`: `"api_keys"`; \}, \{ \}, \{ \}\>; `permissions`: `PgColumn`\<\{ `baseColumn`: `Column`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"permissions"`; `notNull`: `false`; `tableName`: `"api_keys"`; \}, \{ \}, \{ \}\>; `columnType`: `"PgArray"`; `data`: `string`[]; `dataType`: `"array"`; `driverParam`: `string` \| `string`[]; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `true`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"permissions"`; `notNull`: `true`; `tableName`: `"api_keys"`; \}, \{ \}, \{ `baseBuilder`: `PgColumnBuilder`\<\{ `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `name`: `"permissions"`; \}, \{ \}, \{ \}, `ColumnBuilderExtraConfig`\>; `size`: `undefined`; \}\>; `prefix`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"prefix"`; `notNull`: `true`; `tableName`: `"api_keys"`; \}, \{ \}, \{ \}\>; `rateLimit`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgJson"`; `data`: \{ `duration`: `number`; `limit`: `number`; \}; `dataType`: `"json"`; `driverParam`: `unknown`; `enumValues`: `undefined`; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"rate_limit"`; `notNull`: `false`; `tableName`: `"api_keys"`; \}, \{ \}, \{ `$type`: \{ `duration`: `number`; `limit`: `number`; \}; \}\>; `revokedAt`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgTimestamp"`; `data`: `Date`; `dataType`: `"date"`; `driverParam`: `string`; `enumValues`: `undefined`; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"revoked_at"`; `notNull`: `false`; `tableName`: `"api_keys"`; \}, \{ \}, \{ \}\>; `shortToken`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"short_token"`; `notNull`: `true`; `tableName`: `"api_keys"`; \}, \{ \}, \{ \}\>; `tenantId`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"tenant_id"`; `notNull`: `true`; `tableName`: `"api_keys"`; \}, \{ \}, \{ \}\>; \}; `dialect`: `"pg"`; `name`: `"api_keys"`; `schema`: `undefined`; \}\> @@ -41,6 +45,66 @@ Drizzle DB와 API 키 스키마를 받아 저장소를 초기화합니다. ## Methods +### claimRotationEvent() + +> **claimRotationEvent**(`oldKeyId`, `idempotencyKey`, `claimId`, `claimExpiresAt`): `Promise`\<[`ApiKeyRotation`](/api/auth-core/src/type-aliases/apikeyrotation/) \| `null`\> + +#### Parameters + +##### oldKeyId + +`string` + +##### idempotencyKey + +`string` + +##### claimId + +`string` + +##### claimExpiresAt + +`Date` + +#### Returns + +`Promise`\<[`ApiKeyRotation`](/api/auth-core/src/type-aliases/apikeyrotation/) \| `null`\> + +#### Overrides + +[`ApiKeyStore`](/api/auth-core/src/classes/apikeystore/).[`claimRotationEvent`](/api/auth-core/src/classes/apikeystore/#claimrotationevent) + +--- + +### completeRotationEvent() + +> **completeRotationEvent**(`oldKeyId`, `idempotencyKey`, `claimId`): `Promise`\<[`ApiKeyRotation`](/api/auth-core/src/type-aliases/apikeyrotation/) \| `null`\> + +#### Parameters + +##### oldKeyId + +`string` + +##### idempotencyKey + +`string` + +##### claimId + +`string` + +#### Returns + +`Promise`\<[`ApiKeyRotation`](/api/auth-core/src/type-aliases/apikeyrotation/) \| `null`\> + +#### Overrides + +[`ApiKeyStore`](/api/auth-core/src/classes/apikeystore/).[`completeRotationEvent`](/api/auth-core/src/classes/apikeystore/#completerotationevent) + +--- + ### delete() > **delete**(`id`): `Promise`\<`void`\> @@ -61,7 +125,7 @@ API 키를 영구 삭제합니다. [`ApiKeyStore`](/api/auth-core/src/classes/apikeystore/).[`delete`](/api/auth-core/src/classes/apikeystore/#delete) -*** +--- ### findById() @@ -83,7 +147,7 @@ ID로 API 키를 조회합니다. [`ApiKeyStore`](/api/auth-core/src/classes/apikeystore/).[`findById`](/api/auth-core/src/classes/apikeystore/#findbyid) -*** +--- ### findByShortToken() @@ -105,7 +169,7 @@ ID로 API 키를 조회합니다. [`ApiKeyStore`](/api/auth-core/src/classes/apikeystore/).[`findByShortToken`](/api/auth-core/src/classes/apikeystore/#findbyshorttoken) -*** +--- ### listByTenant() @@ -127,7 +191,35 @@ ID로 API 키를 조회합니다. [`ApiKeyStore`](/api/auth-core/src/classes/apikeystore/).[`listByTenant`](/api/auth-core/src/classes/apikeystore/#listbytenant) -*** +--- + +### releaseRotationEvent() + +> **releaseRotationEvent**(`oldKeyId`, `idempotencyKey`, `claimId`): `Promise`\<`void`\> + +#### Parameters + +##### oldKeyId + +`string` + +##### idempotencyKey + +`string` + +##### claimId + +`string` + +#### Returns + +`Promise`\<`void`\> + +#### Overrides + +[`ApiKeyStore`](/api/auth-core/src/classes/apikeystore/).[`releaseRotationEvent`](/api/auth-core/src/classes/apikeystore/#releaserotationevent) + +--- ### revoke() @@ -149,7 +241,29 @@ API 키를 폐기 처리합니다. [`ApiKeyStore`](/api/auth-core/src/classes/apikeystore/).[`revoke`](/api/auth-core/src/classes/apikeystore/#revoke) -*** +--- + +### rotate() + +> **rotate**(`input`): `Promise`\<[`ApiKeyRotation`](/api/auth-core/src/type-aliases/apikeyrotation/)\> + +새 키 저장, 기존 키 폐기, 회전 복구 의도 기록을 한 트랜잭션으로 처리합니다. + +#### Parameters + +##### input + +[`ApiKeyRotationInput`](/api/auth-core/src/type-aliases/apikeyrotationinput/) + +#### Returns + +`Promise`\<[`ApiKeyRotation`](/api/auth-core/src/type-aliases/apikeyrotation/)\> + +#### Overrides + +[`ApiKeyStore`](/api/auth-core/src/classes/apikeystore/).[`rotate`](/api/auth-core/src/classes/apikeystore/#rotate) + +--- ### save() @@ -171,7 +285,7 @@ API 키를 폐기 처리합니다. [`ApiKeyStore`](/api/auth-core/src/classes/apikeystore/).[`save`](/api/auth-core/src/classes/apikeystore/#save) -*** +--- ### updateLastUsed() diff --git a/packages/docs/src/content/docs/api/auth-drizzle/src/functions/addApiKeyRotations.md b/packages/docs/src/content/docs/api/auth-drizzle/src/functions/addApiKeyRotations.md new file mode 100644 index 000000000..a9b7ac679 --- /dev/null +++ b/packages/docs/src/content/docs/api/auth-drizzle/src/functions/addApiKeyRotations.md @@ -0,0 +1,20 @@ +--- +editUrl: false +next: false +prev: false +title: "addApiKeyRotations" +--- + +> **addApiKeyRotations**(`db`): `Promise`\<`void`\> + +API 키 회전 의도 테이블 마이그레이션입니다. + +## Parameters + +### db + +[`ApiKeyRotationMigrationClient`](/api/auth-drizzle/src/type-aliases/apikeyrotationmigrationclient/) + +## Returns + +`Promise`\<`void`\> diff --git a/packages/docs/src/content/docs/api/auth-drizzle/src/functions/removeApiKeyRotations.md b/packages/docs/src/content/docs/api/auth-drizzle/src/functions/removeApiKeyRotations.md new file mode 100644 index 000000000..010931bdb --- /dev/null +++ b/packages/docs/src/content/docs/api/auth-drizzle/src/functions/removeApiKeyRotations.md @@ -0,0 +1,20 @@ +--- +editUrl: false +next: false +prev: false +title: "removeApiKeyRotations" +--- + +> **removeApiKeyRotations**(`db`): `Promise`\<`void`\> + +API 키 회전 의도 테이블 마이그레이션입니다. + +## Parameters + +### db + +[`ApiKeyRotationMigrationClient`](/api/auth-drizzle/src/type-aliases/apikeyrotationmigrationclient/) + +## Returns + +`Promise`\<`void`\> diff --git a/packages/docs/src/content/docs/api/auth-drizzle/src/type-aliases/ApiKeyRotationMigrationClient.md b/packages/docs/src/content/docs/api/auth-drizzle/src/type-aliases/ApiKeyRotationMigrationClient.md new file mode 100644 index 000000000..52fda2ba6 --- /dev/null +++ b/packages/docs/src/content/docs/api/auth-drizzle/src/type-aliases/ApiKeyRotationMigrationClient.md @@ -0,0 +1,24 @@ +--- +editUrl: false +next: false +prev: false +title: "ApiKeyRotationMigrationClient" +--- + +> **ApiKeyRotationMigrationClient** = `object` + +## Methods + +### execute() + +> **execute**(`query`): `Promise`\<`unknown`\> + +#### Parameters + +##### query + +`unknown` + +#### Returns + +`Promise`\<`unknown`\> diff --git a/packages/docs/src/content/docs/api/auth-drizzle/src/variables/apiKeyRotations.md b/packages/docs/src/content/docs/api/auth-drizzle/src/variables/apiKeyRotations.md new file mode 100644 index 000000000..9b5635976 --- /dev/null +++ b/packages/docs/src/content/docs/api/auth-drizzle/src/variables/apiKeyRotations.md @@ -0,0 +1,10 @@ +--- +editUrl: false +next: false +prev: false +title: "apiKeyRotations" +--- + +> `const` **apiKeyRotations**: `PgTableWithColumns`\<\{ `columns`: \{ `createdAt`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgTimestamp"`; `data`: `Date`; `dataType`: `"date"`; `driverParam`: `string`; `enumValues`: `undefined`; `generated`: `undefined`; `hasDefault`: `true`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"created_at"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `eventClaimExpiresAt`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgTimestamp"`; `data`: `Date`; `dataType`: `"date"`; `driverParam`: `string`; `enumValues`: `undefined`; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"event_claim_expires_at"`; `notNull`: `false`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `eventClaimId`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"event_claim_id"`; `notNull`: `false`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `eventId`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"event_id"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `eventOccurredAt`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgTimestamp"`; `data`: `Date`; `dataType`: `"date"`; `driverParam`: `string`; `enumValues`: `undefined`; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"event_occurred_at"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `eventStatus`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `"pending"` \| `"processing"` \| `"completed"`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`"pending"`, `"processing"`, `"completed"`\]; `generated`: `undefined`; `hasDefault`: `true`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"event_status"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `idempotencyKey`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"idempotency_key"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `newKeyId`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgUUID"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: `undefined`; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"new_key_id"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `oldKeyId`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgUUID"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: `undefined`; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `true`; `name`: `"old_key_id"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `recoveryCiphertext`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"recovery_ciphertext"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; `tenantId`: `PgColumn`\<\{ `baseColumn`: `never`; `columnType`: `"PgText"`; `data`: `string`; `dataType`: `"string"`; `driverParam`: `string`; `enumValues`: \[`string`, `...string[]`\]; `generated`: `undefined`; `hasDefault`: `false`; `hasRuntimeDefault`: `false`; `identity`: `undefined`; `isAutoincrement`: `false`; `isPrimaryKey`: `false`; `name`: `"tenant_id"`; `notNull`: `true`; `tableName`: `"api_key_rotations"`; \}, \{ \}, \{ \}\>; \}; `dialect`: `"pg"`; `name`: `"api_key_rotations"`; `schema`: `undefined`; \}\> + +API 키 회전의 멱등성, 복구 자료, 이벤트 전달 상태를 저장합니다. diff --git a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md index 1a8f93ffa..02e13dc4d 100644 --- a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md +++ b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md @@ -7,7 +7,7 @@ description: Generated Croco Problem code registry with recovery and telemetry m > Generated by `pnpm problem-registry:write`. Do not edit this file by hand. -This cookbook documents 523 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`, and generated client union types are emitted at `packages/problems-core/src/generated/problem-code-registry.ts`. +This cookbook documents 526 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`, and generated client union types are emitted at `packages/problems-core/src/generated/problem-code-registry.ts`. ## Index @@ -58,7 +58,10 @@ This cookbook documents 523 public Croco Problem codes. The deterministic JSON r | [`auth-clerk/token-verification-upstream-failed`](#auth-clerk-token-verification-upstream-failed) | InternalServerError | 500 | conditional | operator-only | active | 1 | | [`auth-clerk/webhook-verification-failed`](#auth-clerk-webhook-verification-failed) | Unauthorized | 401 | not-retryable | safe-message | active | 1 | | [`auth-core/api-key-creation-failed`](#auth-core-api-key-creation-failed) | InternalServerError | 500 | conditional | operator-only | active | 1 | +| [`auth-core/api-key-rotation-conflict`](#auth-core-api-key-rotation-conflict) | Conflict | 409 | conditional | safe-message | active | 1 | +| [`auth-core/api-key-rotation-protection-failed`](#auth-core-api-key-rotation-protection-failed) | InternalServerError | 500 | conditional | operator-only | active | 1 | | [`auth-core/auth-provider-unavailable`](#auth-core-auth-provider-unavailable) | InternalServerError | 500 | conditional | operator-only | active | 1 | +| [`auth-core/invalid-api-key-rotation-idempotency-key`](#auth-core-invalid-api-key-rotation-idempotency-key) | ValidationError | 422 | not-retryable | public | active | 1 | | [`auth-core/invalid-permission-action`](#auth-core-invalid-permission-action) | ValidationError | 422 | not-retryable | public | active | 1 | | [`auth-core/invalid-permission-format`](#auth-core-invalid-permission-format) | ValidationError | 422 | not-retryable | public | active | 1 | | [`BAD_REQUEST`](#bad-request) | BadRequest | 400 | not-retryable | public | active | 1 | @@ -1349,6 +1352,42 @@ Sources: - `packages/auth-core/src/libs/problems/AuthProblems.ts:67:3` (problem-class) + + +## `auth-core/api-key-rotation-conflict` + +- Category: `Conflict` +- HTTP status: `409` Conflict +- Retryability: `conditional` +- Redaction policy: `safe-message` +- Lifecycle: `active` +- Cause: The request conflicts with current state or an idempotency constraint. +- User action: Refresh state, resolve the conflict, and retry with the updated intent. +- Operator action: Inspect concurrent writes, idempotency keys, and uniqueness constraints. +- Telemetry: `croco.problem.warning` (warning) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/auth-core/src/libs/problems/AuthProblems.ts:75:3` (problem-class) + + + +## `auth-core/api-key-rotation-protection-failed` + +- Category: `InternalServerError` +- HTTP status: `500` Internal Server Error +- Retryability: `conditional` +- Redaction policy: `operator-only` +- Lifecycle: `active` +- Cause: Croco or an upstream dependency failed after accepting the request. +- User action: Retry later only when the operation is idempotent or the caller owns retry safety. +- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary. +- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/auth-core/src/libs/apikey/ApiKeyRotationProtector.ts:28:5` (problem-constructor) + ## `auth-core/auth-provider-unavailable` @@ -1367,6 +1406,24 @@ Sources: - `packages/auth-core/src/libs/problems/AuthProblems.ts:21:3` (problem-class) + + +## `auth-core/invalid-api-key-rotation-idempotency-key` + +- Category: `ValidationError` +- HTTP status: `422` Validation Error +- Retryability: `not-retryable` +- Redaction policy: `public` +- Lifecycle: `active` +- Cause: The request or generated contract failed schema or semantic validation. +- User action: Fix the invalid fields and retry with schema-conformant input. +- Operator action: Inspect schema diagnostics, generated contracts, and validation metadata. +- Telemetry: `croco.problem.info` (info) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/auth-core/src/libs/problems/AuthProblems.ts:83:3` (problem-class) + ## `auth-core/invalid-permission-action` diff --git a/packages/problems-core/src/generated/problem-code-registry.ts b/packages/problems-core/src/generated/problem-code-registry.ts index 78e06cd29..5127b4fb5 100644 --- a/packages/problems-core/src/generated/problem-code-registry.ts +++ b/packages/problems-core/src/generated/problem-code-registry.ts @@ -3,7 +3,7 @@ import type { ProblemCodeRegistry } from "../libs/ProblemRegistry"; export const CROCO_PROBLEM_CODE_REGISTRY = { version: "croco.problem-code-registry.v1", - problemCount: 523, + problemCount: 526, problems: [ { code: "ACCESS_DENIED", @@ -1400,6 +1400,69 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { }, ], }, + { + code: "auth-core/api-key-rotation-conflict", + category: "Conflict", + status: 409, + title: "Conflict", + cookbookPath: "/reference/problem-recovery-cookbook/#auth-core-api-key-rotation-conflict", + recovery: { + cause: "The request conflicts with current state or an idempotency constraint.", + userAction: "Refresh state, resolve the conflict, and retry with the updated intent.", + operatorAction: "Inspect concurrent writes, idempotency keys, and uniqueness constraints.", + retryability: "conditional", + redactionPolicy: "safe-message", + telemetry: { + eventName: "croco.problem.warning", + severity: "warning", + attributes: ["problem.code", "problem.category", "problem.status"], + }, + }, + lifecycle: { + status: "active", + }, + sources: [ + { + file: "packages/auth-core/src/libs/problems/AuthProblems.ts", + line: 75, + column: 3, + kind: "problem-class", + }, + ], + }, + { + code: "auth-core/api-key-rotation-protection-failed", + category: "InternalServerError", + status: 500, + title: "Internal Server Error", + cookbookPath: + "/reference/problem-recovery-cookbook/#auth-core-api-key-rotation-protection-failed", + recovery: { + cause: "Croco or an upstream dependency failed after accepting the request.", + userAction: + "Retry later only when the operation is idempotent or the caller owns retry safety.", + operatorAction: + "Use traces, logs, and upstream diagnostics to isolate the failing boundary.", + retryability: "conditional", + redactionPolicy: "operator-only", + telemetry: { + eventName: "croco.problem.error", + severity: "error", + attributes: ["problem.code", "problem.category", "problem.status"], + }, + }, + lifecycle: { + status: "active", + }, + sources: [ + { + file: "packages/auth-core/src/libs/apikey/ApiKeyRotationProtector.ts", + line: 28, + column: 5, + kind: "problem-constructor", + }, + ], + }, { code: "auth-core/auth-provider-unavailable", category: "InternalServerError", @@ -1432,6 +1495,37 @@ export const CROCO_PROBLEM_CODE_REGISTRY = { }, ], }, + { + code: "auth-core/invalid-api-key-rotation-idempotency-key", + category: "ValidationError", + status: 422, + title: "Validation Error", + cookbookPath: + "/reference/problem-recovery-cookbook/#auth-core-invalid-api-key-rotation-idempotency-key", + recovery: { + cause: "The request or generated contract failed schema or semantic validation.", + userAction: "Fix the invalid fields and retry with schema-conformant input.", + operatorAction: "Inspect schema diagnostics, generated contracts, and validation metadata.", + retryability: "not-retryable", + redactionPolicy: "public", + telemetry: { + eventName: "croco.problem.info", + severity: "info", + attributes: ["problem.code", "problem.category", "problem.status"], + }, + }, + lifecycle: { + status: "active", + }, + sources: [ + { + file: "packages/auth-core/src/libs/problems/AuthProblems.ts", + line: 83, + column: 3, + kind: "problem-class", + }, + ], + }, { code: "auth-core/invalid-permission-action", category: "ValidationError", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5903c7b3..84eabf4ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -654,12 +654,18 @@ importers: '@types/node': specifier: ^22 version: 22.19.3 + '@types/pg': + specifier: ^8.15.6 + version: 8.20.0 better-sqlite3: specifier: ^11.0.0 version: 11.10.0 drizzle-orm: specifier: 'catalog:' - version: 0.45.2(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(@upstash/redis@1.36.1)(better-sqlite3@11.10.0)(kysely@0.28.17)(pg@8.20.0) + version: 0.45.2(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(@upstash/redis@1.36.1)(better-sqlite3@11.10.0)(kysely@0.28.17)(pg@8.22.0) + pg: + specifier: ^8.20.0 + version: 8.22.0 tsup: specifier: 8.5.1 version: 8.5.1(patch_hash=ce9dbc714c187cea78868f1e68c1a0e5097ddbceb7e976a564d94f2291b5bcb9)(@swc/core@1.15.43)(jiti@2.6.1)(postcss@8.5.18)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0) diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json index 8fa17427d..4cae37b28 100644 --- a/public-api-surface.snapshot.json +++ b/public-api-surface.snapshot.json @@ -3365,12 +3365,24 @@ "source": "./libs/interfaces/AbstractRoleRegistry", "declarationKind": "class" }, + { + "name": "AesGcmApiKeyRotationProtector", + "exportKind": "named", + "source": "./libs/apikey/ApiKeyRotationProtector", + "declarationKind": "class" + }, { "name": "API_KEY_REQUIRED_KEY", "exportKind": "named", "source": "./libs/constants", "declarationKind": "const" }, + { + "name": "API_KEY_ROTATION_PROTECTOR_TOKEN", + "exportKind": "named", + "source": "./libs/apikey/ApiKeyRotationProtector", + "declarationKind": "const" + }, { "name": "API_KEY_STORE_TOKEN", "exportKind": "named", @@ -3419,6 +3431,18 @@ "source": "./libs/problems/AuthProblems", "declarationKind": "class" }, + { + "name": "ApiKeyRotationConflictProblem", + "exportKind": "named", + "source": "./libs/problems/AuthProblems", + "declarationKind": "class" + }, + { + "name": "ApiKeyRotationProtectionProblem", + "exportKind": "named", + "source": "./libs/apikey/ApiKeyRotationProtector", + "declarationKind": "class" + }, { "name": "ApiKeyStore", "exportKind": "named", @@ -3515,6 +3539,12 @@ "source": "./libs/rbac/Permission", "declarationKind": "function" }, + { + "name": "InvalidApiKeyRotationIdempotencyKeyProblem", + "exportKind": "named", + "source": "./libs/problems/AuthProblems", + "declarationKind": "class" + }, { "name": "InvalidPermissionActionProblem", "exportKind": "named", @@ -3589,6 +3619,11 @@ } ], "typeExports": [ + { + "name": "AesGcmApiKeyRotationProtectorOptions", + "exportKind": "named", + "source": "./libs/apikey/ApiKeyRotationProtector" + }, { "name": "ApiKey", "exportKind": "named", @@ -3609,6 +3644,31 @@ "exportKind": "named", "source": "./libs/interfaces/ApiKey" }, + { + "name": "ApiKeyRotation", + "exportKind": "named", + "source": "./libs/interfaces/ApiKey" + }, + { + "name": "ApiKeyRotationInput", + "exportKind": "named", + "source": "./libs/interfaces/ApiKey" + }, + { + "name": "ApiKeyRotationPhaseStatus", + "exportKind": "named", + "source": "./libs/interfaces/ApiKey" + }, + { + "name": "ApiKeyRotationProtectionContext", + "exportKind": "named", + "source": "./libs/apikey/ApiKeyRotationProtector" + }, + { + "name": "ApiKeyRotationProtector", + "exportKind": "named", + "source": "./libs/apikey/ApiKeyRotationProtector" + }, { "name": "AuthProvider", "exportKind": "named", @@ -3664,6 +3724,16 @@ "exportKind": "named", "source": "./libs/rbac/RoleDefinition" }, + { + "name": "RotateApiKeyOptions", + "exportKind": "named", + "source": "./libs/interfaces/ApiKey" + }, + { + "name": "RotateApiKeyResult", + "exportKind": "named", + "source": "./libs/interfaces/ApiKey" + }, { "name": "RouteExecutionContext", "exportKind": "named", @@ -3726,6 +3796,18 @@ ], "sourceEntrypoint": "packages/auth-drizzle/src/index.ts", "runtimeExports": [ + { + "name": "addApiKeyRotations", + "exportKind": "named", + "source": "./migrations/addApiKeyRotations.js", + "declarationKind": "function" + }, + { + "name": "apiKeyRotations", + "exportKind": "named", + "source": "./schema/index.js", + "declarationKind": "const" + }, { "name": "apiKeys", "exportKind": "named", @@ -3756,6 +3838,12 @@ "source": "./libs/DrizzleTenantMappingProvider.js", "declarationKind": "class" }, + { + "name": "removeApiKeyRotations", + "exportKind": "named", + "source": "./migrations/addApiKeyRotations.js", + "declarationKind": "function" + }, { "name": "sessions", "exportKind": "named", @@ -3775,7 +3863,13 @@ "declarationKind": "const" } ], - "typeExports": [] + "typeExports": [ + { + "name": "ApiKeyRotationMigrationClient", + "exportKind": "named", + "source": "./migrations/addApiKeyRotations.js" + } + ] } ] }, diff --git a/scripts/tests/ci-workflow.spec.ts b/scripts/tests/ci-workflow.spec.ts index e0d31e9ee..7c821478c 100644 --- a/scripts/tests/ci-workflow.spec.ts +++ b/scripts/tests/ci-workflow.spec.ts @@ -238,6 +238,18 @@ describe("CI verification profile contract", () => { ); }); + it("routes auth changes to the real PostgreSQL rotation suite", () => { + expect(WORKFLOW).toContain("- 'packages/auth-core/**'"); + expect(WORKFLOW).toContain("- 'packages/auth-drizzle/**'"); + expect(REAL_RESOURCE_JOB).toContain("pnpm build --filter=@croco/auth-drizzle..."); + expect(REAL_RESOURCE_JOB).toContain( + "AUTH_POSTGRES_URL: postgresql://postgres:postgres@127.0.0.1:5432/croco_membership", + ); + expect(REAL_RESOURCE_JOB).toContain( + "pnpm --filter @croco/auth-drizzle exec vitest run src/tests/DrizzleApiKeyStore.postgres.spec.ts", + ); + }); + it("runs typed TestKernel resources against real PostgreSQL and Redis", () => { expect(REAL_RESOURCE_JOB).toContain("pnpm build --filter=@croco/testing-resources..."); expect(REAL_RESOURCE_JOB).toContain("pnpm --filter @croco/testing-resources test:real");