{"version":3,"file":"device-flow-DTP_SZNo.mjs","names":[],"sources":["../src/api/handlers/device-flow.ts"],"sourcesContent":["/**\n * OAuth Device Flow handlers (RFC 8628).\n *\n * EmDash acts as an OAuth 2.0 authorization server. The CLI requests\n * a device code, displays a URL + user code, and polls for a token.\n * The user opens a browser, logs in, enters the code, and the CLI gets\n * an access + refresh token pair.\n *\n * Uses arctic for code generation and @premium-cms/auth for token utilities.\n */\n\nimport type { RoleLevel } from \"@premium-cms/auth\";\nimport { generateCodeVerifier } from \"arctic\";\nimport type { Kysely } from \"kysely\";\n\nimport { resolveUserAuthz } from \"../../auth/authz.js\";\n\nimport {\n\tgeneratePrefixedToken,\n\thashApiToken,\n\tTOKEN_PREFIXES,\n} from \"../../auth/api-tokens.js\";\nimport { withTransaction } from \"../../database/transaction.js\";\nimport type { Database } from \"../../database/types.js\";\nimport type { ApiResult } from \"../types.js\";\nimport { lookupOAuthClient } from \"./oauth-clients.js\";\nimport { lookupUserRoleAndStatus } from \"./oauth-user-lookup.js\";\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Device codes expire after 15 minutes */\nconst DEVICE_CODE_TTL_SECONDS = 15 * 60;\n\n/** Default polling interval in seconds */\nconst DEFAULT_INTERVAL = 5;\n\n/** RFC 8628 §3.5: interval increase on slow_down */\nconst SLOW_DOWN_INCREMENT = 5;\n\n/** Maximum slow_down interval cap (seconds) */\nconst MAX_SLOW_DOWN_INTERVAL = 60;\n\n/** Access token TTL: 1 hour */\nconst ACCESS_TOKEN_TTL_SECONDS = 60 * 60;\n\n/** Refresh token TTL: 90 days */\nconst REFRESH_TOKEN_TTL_SECONDS = 90 * 24 * 60 * 60;\n\n/** Default for CLI login: no explicit policies — act as the user (every policy the role holds). */\nconst DEFAULT_SCOPES: string[] = [];\n\n/** Pattern to normalize user codes (strip hyphens) */\nconst HYPHEN_PATTERN = /-/g;\n\n/** Characters for user codes (uppercase, no ambiguous chars like 0/O, 1/I) */\nconst USER_CODE_CHARS = \"ABCDEFGHJKLMNPQRSTUVWXYZ23456789\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface DeviceCodeResponse {\n\tdevice_code: string;\n\tuser_code: string;\n\tverification_uri: string;\n\texpires_in: number;\n\tinterval: number;\n}\n\nexport interface TokenResponse {\n\taccess_token: string;\n\trefresh_token: string;\n\ttoken_type: \"Bearer\";\n\texpires_in: number;\n\tscope: string;\n}\n\n// RFC 8628 error codes\nexport type DeviceFlowError =\n\t| \"authorization_pending\"\n\t| \"slow_down\"\n\t| \"expired_token\"\n\t| \"access_denied\";\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** Generate a short human-readable user code (XXXX-XXXX) */\nfunction generateUserCode(): string {\n\tconst bytes = new Uint8Array(8);\n\tcrypto.getRandomValues(bytes);\n\tconst chars = Array.from(bytes, (b) => USER_CODE_CHARS[b % USER_CODE_CHARS.length]).join(\"\");\n\treturn `${chars.slice(0, 4)}-${chars.slice(4, 8)}`;\n}\n\n/** Get an ISO datetime string offset from now */\nfunction expiresAt(seconds: number): string {\n\treturn new Date(Date.now() + seconds * 1000).toISOString();\n}\n\nconst POLICY_SLUG = /^[a-z0-9][a-z0-9_-]*$/;\n\n/** Policy slugs the user's role holds, or the requested subset of them. */\nasync function clampToHeldPolicies(\n\tdb: Kysely<Database>,\n\tuser: { role: number; roleId: string | null },\n\trequested: string[],\n): Promise<string[]> {\n\tconst { rolePolicies } = await resolveUserAuthz(db, user);\n\tif (requested.length === 0) return [...rolePolicies];\n\tconst held = new Set(rolePolicies);\n\treturn requested.filter((slug) => held.has(slug));\n}\n\n/** OAuth scopes are policy slugs; drop anything that is not one. */\nfunction normalizeScopes(requested?: string[]): string[] {\n\tif (!requested || requested.length === 0) {\n\t\treturn [...DEFAULT_SCOPES];\n\t}\n\treturn [...new Set(requested.filter((s) => POLICY_SLUG.test(s)))];\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\n/**\n * POST /oauth/device/code\n *\n * Issue a device code + user code. The CLI displays the user code\n * and tells the user to open the verification URI.\n */\nexport async function handleDeviceCodeRequest(\n\tdb: Kysely<Database>,\n\tinput: {\n\t\tclient_id?: string;\n\t\tscope?: string;\n\t},\n\tverificationUri: string,\n): Promise<ApiResult<DeviceCodeResponse>> {\n\ttry {\n\t\t// Note: client_id is accepted but not validated against _emdash_oauth_clients\n\t\t// because the CLI uses a well-known built-in client ID (\"emdash-cli\") that\n\t\t// isn't stored in the DB. Full client_id validation + scope clamping for the\n\t\t// device flow is tracked as a follow-up.\n\n\t\t// Parse and validate scopes\n\t\tconst requestedScopes = input.scope ? input.scope.split(\" \").filter(Boolean) : [];\n\t\tconst scopes = normalizeScopes(requestedScopes);\n\n\n\t\tconst deviceCode = generateCodeVerifier();\n\t\tconst userCode = generateUserCode();\n\t\tconst expires = expiresAt(DEVICE_CODE_TTL_SECONDS);\n\n\t\tawait db\n\t\t\t.insertInto(\"_emdash_device_codes\")\n\t\t\t.values({\n\t\t\t\tdevice_code: deviceCode,\n\t\t\t\tuser_code: userCode,\n\t\t\t\tscopes: JSON.stringify(scopes),\n\t\t\t\tstatus: \"pending\",\n\t\t\t\texpires_at: expires,\n\t\t\t\tinterval: DEFAULT_INTERVAL,\n\t\t\t})\n\t\t\t.execute();\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\tdevice_code: deviceCode,\n\t\t\t\tuser_code: userCode,\n\t\t\t\tverification_uri: verificationUri,\n\t\t\t\texpires_in: DEVICE_CODE_TTL_SECONDS,\n\t\t\t\tinterval: DEFAULT_INTERVAL,\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"DEVICE_CODE_ERROR\",\n\t\t\t\tmessage: \"Failed to create device code\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n/**\n * POST /oauth/device/token\n *\n * CLI polls this endpoint with the device_code. Returns:\n * - 200 with tokens if authorized\n * - 400 with error \"authorization_pending\" while waiting\n * - 400 with error \"slow_down\" if polling too fast\n * - 400 with error \"expired_token\" if the code expired\n * - 400 with error \"access_denied\" if the user denied\n */\nexport async function handleDeviceTokenExchange(\n\tdb: Kysely<Database>,\n\tinput: {\n\t\tdevice_code: string;\n\t\tgrant_type: string;\n\t},\n): Promise<\n\tApiResult<TokenResponse> & { deviceFlowError?: DeviceFlowError; deviceFlowInterval?: number }\n> {\n\ttry {\n\t\t// Validate grant_type\n\t\tif (input.grant_type !== \"urn:ietf:params:oauth:grant-type:device_code\") {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"UNSUPPORTED_GRANT_TYPE\", message: \"Invalid grant_type\" },\n\t\t\t};\n\t\t}\n\n\t\t// Look up the device code\n\t\tconst row = await db\n\t\t\t.selectFrom(\"_emdash_device_codes\")\n\t\t\t.selectAll()\n\t\t\t.where(\"device_code\", \"=\", input.device_code)\n\t\t\t.executeTakeFirst();\n\n\t\tif (!row) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_GRANT\", message: \"Invalid device code\" },\n\t\t\t};\n\t\t}\n\n\t\tconst now = new Date();\n\n\t\t// Check expiry\n\t\tif (new Date(row.expires_at) < now) {\n\t\t\t// Clean up expired code\n\t\t\tawait db\n\t\t\t\t.deleteFrom(\"_emdash_device_codes\")\n\t\t\t\t.where(\"device_code\", \"=\", input.device_code)\n\t\t\t\t.execute();\n\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\tdeviceFlowError: \"expired_token\",\n\t\t\t\terror: { code: \"expired_token\", message: \"The device code has expired\" },\n\t\t\t};\n\t\t}\n\n\t\t// Check status\n\t\tif (row.status === \"denied\") {\n\t\t\t// Clean up denied code\n\t\t\tawait db\n\t\t\t\t.deleteFrom(\"_emdash_device_codes\")\n\t\t\t\t.where(\"device_code\", \"=\", input.device_code)\n\t\t\t\t.execute();\n\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\tdeviceFlowError: \"access_denied\",\n\t\t\t\terror: { code: \"access_denied\", message: \"The user denied the request\" },\n\t\t\t};\n\t\t}\n\n\t\tif (row.status === \"pending\") {\n\t\t\t// RFC 8628 §3.5: slow_down enforcement during polling phase.\n\t\t\t// Only applies while waiting for authorization — once authorized,\n\t\t\t// the final exchange proceeds without throttling.\n\t\t\tif (row.last_polled_at) {\n\t\t\t\tconst lastPolled = new Date(row.last_polled_at);\n\t\t\t\tconst elapsedSeconds = (now.getTime() - lastPolled.getTime()) / 1000;\n\n\t\t\t\tif (elapsedSeconds < row.interval) {\n\t\t\t\t\t// Too fast — increase interval by 5s per RFC 8628 §3.5, capped at 60s\n\t\t\t\t\tconst newInterval = Math.min(row.interval + SLOW_DOWN_INCREMENT, MAX_SLOW_DOWN_INTERVAL);\n\t\t\t\t\tawait db\n\t\t\t\t\t\t.updateTable(\"_emdash_device_codes\")\n\t\t\t\t\t\t.set({\n\t\t\t\t\t\t\tinterval: newInterval,\n\t\t\t\t\t\t\tlast_polled_at: now.toISOString(),\n\t\t\t\t\t\t})\n\t\t\t\t\t\t.where(\"device_code\", \"=\", input.device_code)\n\t\t\t\t\t\t.execute();\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\tdeviceFlowError: \"slow_down\",\n\t\t\t\t\t\tdeviceFlowInterval: newInterval,\n\t\t\t\t\t\terror: { code: \"slow_down\", message: \"Polling too fast\" },\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Update last_polled_at for future slow_down checks\n\t\t\tawait db\n\t\t\t\t.updateTable(\"_emdash_device_codes\")\n\t\t\t\t.set({ last_polled_at: now.toISOString() })\n\t\t\t\t.where(\"device_code\", \"=\", input.device_code)\n\t\t\t\t.execute();\n\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\tdeviceFlowError: \"authorization_pending\",\n\t\t\t\terror: { code: \"authorization_pending\", message: \"Authorization pending\" },\n\t\t\t};\n\t\t}\n\n\t\tif (row.status !== \"authorized\" || !row.user_id) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_GRANT\", message: \"Invalid device code state\" },\n\t\t\t};\n\t\t}\n\n\t\t// Generate tokens before consuming the device code so that if\n\t\t// generation fails, the code is still available for retry.\n\t\tconst accessToken = generatePrefixedToken(TOKEN_PREFIXES.OAUTH_ACCESS);\n\t\tconst accessExpires = expiresAt(ACCESS_TOKEN_TTL_SECONDS);\n\t\tconst refreshToken = generatePrefixedToken(TOKEN_PREFIXES.OAUTH_REFRESH);\n\t\tconst refreshExpires = expiresAt(REFRESH_TOKEN_TTL_SECONDS);\n\n\t\t// Atomically consume the device code and create tokens in a single\n\t\t// transaction. DELETE...RETURNING prevents TOCTOU: two concurrent\n\t\t// requests race on the DELETE, only one gets a row back. Wrapping\n\t\t// in a transaction ensures the code isn't consumed if token storage fails.\n\t\tconst result = await withTransaction(db, async (trx) => {\n\t\t\tconst consumed = await trx\n\t\t\t\t.deleteFrom(\"_emdash_device_codes\")\n\t\t\t\t.where(\"device_code\", \"=\", input.device_code)\n\t\t\t\t.where(\"status\", \"=\", \"authorized\")\n\t\t\t\t.returningAll()\n\t\t\t\t.executeTakeFirst();\n\n\t\t\tif (!consumed) return null;\n\n\t\t\tif (!consumed.user_id) return null;\n\n\t\t\tconst scopes = JSON.parse(consumed.scopes) as string[];\n\n\t\t\tawait trx\n\t\t\t\t.insertInto(\"_emdash_oauth_tokens\")\n\t\t\t\t.values({\n\t\t\t\t\ttoken_hash: accessToken.hash,\n\t\t\t\t\ttoken_type: \"access\",\n\t\t\t\t\tuser_id: consumed.user_id,\n\t\t\t\t\tscopes: JSON.stringify(scopes),\n\t\t\t\t\tclient_type: \"cli\",\n\t\t\t\t\texpires_at: accessExpires,\n\t\t\t\t\trefresh_token_hash: refreshToken.hash,\n\t\t\t\t})\n\t\t\t\t.execute();\n\n\t\t\tawait trx\n\t\t\t\t.insertInto(\"_emdash_oauth_tokens\")\n\t\t\t\t.values({\n\t\t\t\t\ttoken_hash: refreshToken.hash,\n\t\t\t\t\ttoken_type: \"refresh\",\n\t\t\t\t\tuser_id: consumed.user_id,\n\t\t\t\t\tscopes: JSON.stringify(scopes),\n\t\t\t\t\tclient_type: \"cli\",\n\t\t\t\t\texpires_at: refreshExpires,\n\t\t\t\t\trefresh_token_hash: null,\n\t\t\t\t})\n\t\t\t\t.execute();\n\n\t\t\treturn { scopes };\n\t\t});\n\n\t\tif (!result) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_GRANT\", message: \"Device code already consumed\" },\n\t\t\t};\n\t\t}\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\taccess_token: accessToken.raw,\n\t\t\t\trefresh_token: refreshToken.raw,\n\t\t\t\ttoken_type: \"Bearer\",\n\t\t\t\texpires_in: ACCESS_TOKEN_TTL_SECONDS,\n\t\t\t\tscope: result.scopes.join(\" \"),\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"TOKEN_EXCHANGE_ERROR\",\n\t\t\t\tmessage: \"Failed to exchange device code\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n/**\n * POST /oauth/device/authorize\n *\n * The user submits the user_code after logging in via the browser.\n * This authorizes the device code, allowing the CLI to exchange it for tokens.\n *\n * Scopes are clamped to the user's role at this point. The stored scopes\n * are replaced with the intersection of requested scopes and the scopes\n * the user's role permits. This prevents scope escalation.\n */\nexport async function handleDeviceAuthorize(\n\tdb: Kysely<Database>,\n\tuserId: string,\n\t/** The approving user's role linkage — what their tokens may never exceed. */\n\tuserLinkage: { role: RoleLevel; roleId: string | null },\n\tinput: {\n\t\tuser_code: string;\n\t\taction?: \"approve\" | \"deny\";\n\t},\n): Promise<ApiResult<{ authorized: boolean }>> {\n\ttry {\n\t\t// Normalize user code (strip hyphens, uppercase)\n\t\tconst normalizedCode = input.user_code.replace(HYPHEN_PATTERN, \"\").toUpperCase();\n\n\t\t// Look up the device code by user_code\n\t\tconst row = await db\n\t\t\t.selectFrom(\"_emdash_device_codes\")\n\t\t\t.selectAll()\n\t\t\t.where(\"status\", \"=\", \"pending\")\n\t\t\t.execute();\n\n\t\t// Find the matching code (strip hyphens for comparison)\n\t\tconst match = row.find(\n\t\t\t(r) => r.user_code.replace(HYPHEN_PATTERN, \"\").toUpperCase() === normalizedCode,\n\t\t);\n\n\t\tif (!match) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_CODE\", message: \"Invalid or expired code\" },\n\t\t\t};\n\t\t}\n\n\t\t// Check expiry\n\t\tif (new Date(match.expires_at) < new Date()) {\n\t\t\tawait db\n\t\t\t\t.deleteFrom(\"_emdash_device_codes\")\n\t\t\t\t.where(\"device_code\", \"=\", match.device_code)\n\t\t\t\t.execute();\n\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"EXPIRED_CODE\", message: \"This code has expired\" },\n\t\t\t};\n\t\t}\n\n\t\tconst action = input.action ?? \"approve\";\n\n\t\tif (action === \"deny\") {\n\t\t\tawait db\n\t\t\t\t.updateTable(\"_emdash_device_codes\")\n\t\t\t\t.set({ status: \"denied\" })\n\t\t\t\t.where(\"device_code\", \"=\", match.device_code)\n\t\t\t\t.execute();\n\n\t\t\treturn { success: true, data: { authorized: false } };\n\t\t}\n\n\t\t// Clamp the requested policies to those the user's role holds; an\n\t\t// empty request means every policy the role holds.\n\t\tconst requestedScopes = JSON.parse(match.scopes) as string[];\n\t\tconst effectiveScopes = await clampToHeldPolicies(db, userLinkage, requestedScopes);\n\n\t\tif (effectiveScopes.length === 0) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"INSUFFICIENT_ROLE\",\n\t\t\t\t\tmessage: \"Your role does not permit any of the requested scopes\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// Approve: set user_id, status, and clamped scopes\n\t\tawait db\n\t\t\t.updateTable(\"_emdash_device_codes\")\n\t\t\t.set({\n\t\t\t\tstatus: \"authorized\",\n\t\t\t\tuser_id: userId,\n\t\t\t\tscopes: JSON.stringify(effectiveScopes),\n\t\t\t})\n\t\t\t.where(\"device_code\", \"=\", match.device_code)\n\t\t\t.execute();\n\n\t\treturn { success: true, data: { authorized: true } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"AUTHORIZE_ERROR\",\n\t\t\t\tmessage: \"Failed to authorize device\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n/**\n * POST /oauth/token/refresh\n *\n * Exchange a refresh token for a new access token.\n * The refresh token itself is not rotated (per spec: optional rotation).\n */\nexport async function handleTokenRefresh(\n\tdb: Kysely<Database>,\n\tinput: {\n\t\trefresh_token: string;\n\t\tgrant_type: string;\n\t},\n): Promise<ApiResult<TokenResponse>> {\n\ttry {\n\t\tif (input.grant_type !== \"refresh_token\") {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"UNSUPPORTED_GRANT_TYPE\", message: \"Invalid grant_type\" },\n\t\t\t};\n\t\t}\n\n\t\tif (!input.refresh_token.startsWith(TOKEN_PREFIXES.OAUTH_REFRESH)) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_GRANT\", message: \"Invalid refresh token format\" },\n\t\t\t};\n\t\t}\n\n\t\tconst refreshHash = hashApiToken(input.refresh_token);\n\n\t\tconst row = await db\n\t\t\t.selectFrom(\"_emdash_oauth_tokens\")\n\t\t\t.selectAll()\n\t\t\t.where(\"token_hash\", \"=\", refreshHash)\n\t\t\t.where(\"token_type\", \"=\", \"refresh\")\n\t\t\t.executeTakeFirst();\n\n\t\tif (!row) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_GRANT\", message: \"Invalid refresh token\" },\n\t\t\t};\n\t\t}\n\n\t\t// Check expiry\n\t\tif (new Date(row.expires_at) < new Date()) {\n\t\t\t// Clean up expired refresh token and its access tokens\n\t\t\tawait db.deleteFrom(\"_emdash_oauth_tokens\").where(\"token_hash\", \"=\", refreshHash).execute();\n\t\t\tawait db\n\t\t\t\t.deleteFrom(\"_emdash_oauth_tokens\")\n\t\t\t\t.where(\"refresh_token_hash\", \"=\", refreshHash)\n\t\t\t\t.execute();\n\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_GRANT\", message: \"Refresh token expired\" },\n\t\t\t};\n\t\t}\n\n\t\t// SEC-42: Revalidate user role before issuing new access token.\n\t\t// SEC-43: Reject refresh if user is disabled or deleted.\n\t\tconst userInfo = await lookupUserRoleAndStatus(db, row.user_id);\n\t\tif (!userInfo) {\n\t\t\t// User no longer exists — revoke all their tokens\n\t\t\tawait db.deleteFrom(\"_emdash_oauth_tokens\").where(\"user_id\", \"=\", row.user_id).execute();\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_GRANT\", message: \"User not found\" },\n\t\t\t};\n\t\t}\n\n\t\tif (userInfo.disabled) {\n\t\t\t// User is disabled — revoke all their tokens\n\t\t\tawait db.deleteFrom(\"_emdash_oauth_tokens\").where(\"user_id\", \"=\", row.user_id).execute();\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_GRANT\", message: \"User account is disabled\" },\n\t\t\t};\n\t\t}\n\n\t\t// Revalidate the stored policies against what the user's role holds\n\t\t// today: a demoted user's refresh token must not keep old grants.\n\t\tconst storedScopes = JSON.parse(row.scopes) as string[];\n\t\tlet scopes = await clampToHeldPolicies(\n\t\t\tdb,\n\t\t\t{ role: userInfo.role, roleId: userInfo.roleId ?? null },\n\t\t\tstoredScopes,\n\t\t);\n\n\t\t// SEC-41: Intersect with the client's registered scopes (if any).\n\t\t// Same check as the approval path — a client registered with limited\n\t\t// scopes should never receive elevated scopes on refresh, even if the\n\t\t// user's role would allow them.\n\t\tif (row.client_id) {\n\t\t\tconst client = await lookupOAuthClient(db, row.client_id);\n\t\t\tif (client?.scopes?.length) {\n\t\t\t\tscopes = scopes.filter((s: string) => client.scopes!.includes(s));\n\t\t\t}\n\t\t}\n\n\t\tif (scopes.length === 0) {\n\t\t\t// User's role no longer supports any of the token's scopes — revoke\n\t\t\tawait db.deleteFrom(\"_emdash_oauth_tokens\").where(\"token_hash\", \"=\", refreshHash).execute();\n\t\t\tawait db\n\t\t\t\t.deleteFrom(\"_emdash_oauth_tokens\")\n\t\t\t\t.where(\"refresh_token_hash\", \"=\", refreshHash)\n\t\t\t\t.execute();\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"INVALID_GRANT\",\n\t\t\t\t\tmessage: \"User role no longer supports any of the token's scopes\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// Delete old access tokens for this refresh token\n\t\tawait db\n\t\t\t.deleteFrom(\"_emdash_oauth_tokens\")\n\t\t\t.where(\"refresh_token_hash\", \"=\", refreshHash)\n\t\t\t.where(\"token_type\", \"=\", \"access\")\n\t\t\t.execute();\n\n\t\t// Generate new access token\n\t\tconst accessToken = generatePrefixedToken(TOKEN_PREFIXES.OAUTH_ACCESS);\n\t\tconst accessExpires = expiresAt(ACCESS_TOKEN_TTL_SECONDS);\n\n\t\tawait db\n\t\t\t.insertInto(\"_emdash_oauth_tokens\")\n\t\t\t.values({\n\t\t\t\ttoken_hash: accessToken.hash,\n\t\t\t\ttoken_type: \"access\",\n\t\t\t\tuser_id: row.user_id,\n\t\t\t\tscopes: JSON.stringify(scopes),\n\t\t\t\tclient_type: row.client_type,\n\t\t\t\texpires_at: accessExpires,\n\t\t\t\trefresh_token_hash: refreshHash,\n\t\t\t})\n\t\t\t.execute();\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\taccess_token: accessToken.raw,\n\t\t\t\trefresh_token: input.refresh_token, // Return same refresh token\n\t\t\t\ttoken_type: \"Bearer\",\n\t\t\t\texpires_in: ACCESS_TOKEN_TTL_SECONDS,\n\t\t\t\tscope: scopes.join(\" \"),\n\t\t\t},\n\t\t};\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"TOKEN_REFRESH_ERROR\",\n\t\t\t\tmessage: \"Failed to refresh token\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n/**\n * POST /oauth/token/revoke\n *\n * Revoke an access or refresh token. If a refresh token is revoked,\n * also revoke all associated access tokens.\n *\n * Per RFC 7009, this endpoint always returns 200 (even for invalid tokens).\n */\nexport async function handleTokenRevoke(\n\tdb: Kysely<Database>,\n\tinput: {\n\t\ttoken: string;\n\t},\n): Promise<ApiResult<{ revoked: boolean }>> {\n\ttry {\n\t\tconst hash = hashApiToken(input.token);\n\n\t\t// Look up the token\n\t\tconst row = await db\n\t\t\t.selectFrom(\"_emdash_oauth_tokens\")\n\t\t\t.select([\"token_hash\", \"token_type\", \"refresh_token_hash\"])\n\t\t\t.where(\"token_hash\", \"=\", hash)\n\t\t\t.executeTakeFirst();\n\n\t\tif (!row) {\n\t\t\t// Per RFC 7009: always 200, even for invalid tokens\n\t\t\treturn { success: true, data: { revoked: true } };\n\t\t}\n\n\t\tif (row.token_type === \"refresh\") {\n\t\t\t// Revoke refresh token and all its access tokens\n\t\t\tawait db.deleteFrom(\"_emdash_oauth_tokens\").where(\"refresh_token_hash\", \"=\", hash).execute();\n\t\t\tawait db.deleteFrom(\"_emdash_oauth_tokens\").where(\"token_hash\", \"=\", hash).execute();\n\t\t} else {\n\t\t\t// Revoke just the access token\n\t\t\tawait db.deleteFrom(\"_emdash_oauth_tokens\").where(\"token_hash\", \"=\", hash).execute();\n\t\t}\n\n\t\treturn { success: true, data: { revoked: true } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"TOKEN_REVOKE_ERROR\",\n\t\t\t\tmessage: \"Failed to revoke token\",\n\t\t\t},\n\t\t};\n\t}\n}\n"],"mappings":"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"}