import { Env, TokenResponse } from '../types'; import { StorageService } from '../services/storage'; import { AuthService } from '../services/auth'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; import { LIMITS } from '../config/limits'; import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { createRefreshToken } from '../utils/jwt'; import { readAuthRequestDeviceInfo } from '../utils/device'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { generateUUID } from '../utils/uuid'; import { issueSendAccessToken } from './sends'; import { registerMobilePushDevice } from '../services/push-relay'; import { buildAccountKeys, buildUserDecryptionOptions, } from '../utils/user-decryption'; import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events'; import { assertAccountPasskeyCredential, buildAccountPasskeyTokenUserDecryptionOption, } from './account-passkeys'; import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_REMEMBER = 5; const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8; const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh'; // Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows // the official Identity provider enum (RecoveryCode = 8), while request parsing remains // compatible with older/local provider values. const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1'; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100; function resolveTotpSecret(userSecret: string | null): string | null { if (userSecret && isTotpEnabled(userSecret)) { return userSecret; } return null; } async function resolveDeviceSession( storage: StorageService, userId: string, deviceInfo: ReturnType ): Promise<{ identifier: string; sessionStamp: string } | null> { if (!deviceInfo.deviceIdentifier) return null; const existingDevice = await storage.getDevice(userId, deviceInfo.deviceIdentifier); const sessionStamp = String(existingDevice?.sessionStamp || '').trim() || generateUUID(); return { identifier: deviceInfo.deviceIdentifier, sessionStamp }; } function readDevicePushToken(body: Record): string { return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim(); } async function persistIdentityDevicePushToken( env: Env, storage: StorageService, userId: string, deviceSession: { identifier: string; sessionStamp: string } | null, deviceType: number, body: Record ): Promise { if (!deviceSession) return; const pushToken = readDevicePushToken(body); if (!pushToken) return; const device = await storage.getDevice(userId, deviceSession.identifier); if (!device) return; const pushUuid = device.pushUuid || generateUUID(); await storage.updateDevicePushToken(userId, deviceSession.identifier, pushUuid, pushToken); const registered = await registerMobilePushDevice(env, { userId, deviceIdentifier: deviceSession.identifier, type: device.type || deviceType, pushUuid, pushToken, }); console.info('Mobile push token updated from identity token request', { userId, deviceIdentifier: deviceSession.identifier, deviceType: device.type || deviceType, pushUuid, pushTokenLength: pushToken.length, relayRegistered: registered, }); } function shouldUseWebSession(request: Request): boolean { return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1'; } function parseCookieValue(request: Request, name: string): string | null { const rawCookie = String(request.headers.get('Cookie') || '').trim(); if (!rawCookie) return null; for (const part of rawCookie.split(';')) { const [key, ...rest] = part.trim().split('='); if (key !== name) continue; const value = rest.join('=').trim(); return value ? decodeURIComponent(value) : null; } return null; } function constantTimeEquals(a: string, b: string): boolean { const encA = new TextEncoder().encode(a); const encB = new TextEncoder().encode(b); if (encA.length !== encB.length) return false; let diff = 0; for (let i = 0; i < encA.length; i++) { diff |= encA[i] ^ encB[i]; } return diff === 0; } function readBodyValue(body: Record, names: string[]): string | undefined { for (const name of names) { const value = body[name]; if (value != null) return value; } return undefined; } function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string { const isHttps = new URL(request.url).protocol === 'https:'; const parts = [ `${WEB_REFRESH_COOKIE}=${encodeURIComponent(refreshToken)}`, 'Path=/identity/connect', 'HttpOnly', 'SameSite=Strict', `Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`, ]; if (isHttps) parts.push('Secure'); return parts.join('; '); } function buildClearedRefreshCookie(request: Request): string { return buildRefreshCookie(request, '', 0); } function withWebRefreshCookie(request: Request, response: Response, refreshToken: string | null): Response { const headers = new Headers(response.headers); headers.append( 'Set-Cookie', refreshToken ? buildRefreshCookie(request, refreshToken, Math.floor(LIMITS.auth.refreshTokenTtlMs / 1000)) : buildClearedRefreshCookie(request) ); return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }); } function buildPreloginResponse( email: string, kdfType: number, kdfIterations: number, kdfMemory: number | null, kdfParallelism: number | null ): Record { return { kdf: kdfType, kdfIterations, kdfMemory, kdfParallelism, KdfSettings: { KdfType: kdfType, Iterations: kdfIterations, Memory: kdfMemory, Parallelism: kdfParallelism, }, Salt: email.toLowerCase(), }; } function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] { return { minComplexity: 0, minLength: 0, requireUpper: false, requireLower: false, requireNumbers: false, requireSpecial: false, enforceOnLogin: false, Object: 'masterPasswordPolicy', object: 'masterPasswordPolicy', }; } function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response { // Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only. // Clients expose recovery-code entry points themselves; Android 2026.4 fails to // parse the challenge if an unknown recovery provider key such as "8" is included. const providers = [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)]; const providers2: Record = {}; for (const provider of providers) providers2[provider] = { Email: null }; const customResponse = { TwoFactorProviders: providers, TwoFactorProviders2: providers2, SsoEmail2faSessionToken: null, MasterPasswordPolicy: masterPasswordPolicyResponse(), }; // Bitwarden clients rely on these fields to trigger the 2FA UI flow. return jsonResponse( { error: 'invalid_grant', error_description: message, Error: 'invalid_grant', ErrorDescription: message, ErrorMessage: message, TwoFactorProviders: customResponse.TwoFactorProviders, TwoFactorProviders2: customResponse.TwoFactorProviders2, // Required by current Android parser (nullable value is acceptable). SsoEmail2faSessionToken: customResponse.SsoEmail2faSessionToken, MasterPasswordPolicy: customResponse.MasterPasswordPolicy, CustomResponse: customResponse, ErrorModel: { Message: message, Object: 'error', }, }, 400 ); } async function recordFailedLoginAndBuildResponse( rateLimit: RateLimitService, loginIdentifier: string, message: string ): Promise { const result = await rateLimit.recordFailedLogin(loginIdentifier); if (result.locked) { return identityErrorResponse( `Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`, 'TooManyRequests', 429 ); } return identityErrorResponse(message, 'invalid_grant', 400); } async function recordFailedTwoFactorAndBuildResponse( rateLimit: RateLimitService, loginIdentifier: string ): Promise { const failed = await rateLimit.recordFailedLogin(loginIdentifier); if (failed.locked) { return identityErrorResponse( `Too many failed login attempts. Account locked for ${Math.ceil(failed.retryAfterSeconds! / 60)} minutes.`, 'TooManyRequests', 429 ); } return identityErrorResponse('Two-step token is invalid. Try again.', 'invalid_grant', 400); } // POST /identity/connect/token export async function handleToken(request: Request, env: Env): Promise { const storage = new StorageService(env.DB); const auth = new AuthService(env); const rateLimit = new RateLimitService(env.DB); let body: Record; const contentType = request.headers.get('content-type') || ''; try { if (contentType.includes('application/x-www-form-urlencoded')) { const formData = await request.formData(); body = Object.fromEntries(formData.entries()) as Record; } else { body = await request.json(); } } catch { return identityErrorResponse('Invalid request payload', 'invalid_request', 400); } const grantType = body.grant_type; const clientIdentifier = getClientIdentifier(request); if (!clientIdentifier) { return identityErrorResponse('Client IP is required', 'invalid_request', 403); } if (grantType === 'password') { // Login with password const email = body.username?.toLowerCase(); const passwordHash = body.password; const authRequestId = readBodyValue(body, ['authRequest', 'AuthRequest']); const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']); const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']); const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']); const loginIdentifier = clientIdentifier; const deviceInfo = readAuthRequestDeviceInfo(body, request); if (!email || !passwordHash) { // Bitwarden clients expect OAuth-style error fields. return identityErrorResponse('Email and password are required', 'invalid_request', 400); } // Check login lockout before user lookup to reduce user-enumeration signal const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); if (!loginCheck.allowed) { return identityErrorResponse( `Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`, 'TooManyRequests', 429 ); } const user = await storage.getUser(email); if (!user) { await rateLimit.recordFailedLogin(loginIdentifier); return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400); } if (user.status !== 'active') { await rateLimit.recordFailedLogin(loginIdentifier); await safeWriteAuditEvent(env, { actorUserId: user.id, action: 'auth.login.failed.user_inactive', category: 'auth', level: 'warn', targetType: 'user', targetId: user.id, metadata: { grantType, deviceIdentifier: deviceInfo.deviceIdentifier, ...auditRequestMetadata(request), }, }); return identityErrorResponse('Account is disabled', 'invalid_grant', 400); } let validatedAuthRequestId: string | null = null; let valid = false; const normalizedAuthRequestId = String(authRequestId || '').trim(); if (normalizedAuthRequestId) { const authRequest = await storage.getAuthRequestById(normalizedAuthRequestId); valid = !!( authRequest && authRequest.userId === user.id && authRequest.type === 0 && authRequest.approved === true && authRequest.responseDate && !authRequest.authenticationDate && !isAuthRequestExpired(authRequest) && constantTimeEquals(authRequest.accessCode, passwordHash) ); if (valid) { validatedAuthRequestId = authRequest!.id; } } else { valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email); } if (!valid) { await safeWriteAuditEvent(env, { actorUserId: user.id, action: normalizedAuthRequestId ? 'auth.login.failed.bad_auth_request' : 'auth.login.failed.bad_password', category: 'auth', level: 'warn', targetType: 'user', targetId: user.id, metadata: { grantType, deviceIdentifier: deviceInfo.deviceIdentifier, ...auditRequestMetadata(request), }, }); return recordFailedLoginAndBuildResponse( rateLimit, loginIdentifier, 'Username or password is incorrect. Try again' ); } // Optional 2FA: enabled only by per-user secret. let trustedTwoFactorTokenToReturn: string | undefined; const effectiveTotpSecret = resolveTotpSecret(user.totpSecret); if (effectiveTotpSecret) { const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim(); const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim(); let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim()); const hasProvider = normalizedTwoFactorProvider.length > 0; const hasToken = normalizedTwoFactorToken.length > 0; // Upstream-compatible behavior: if 2FA is required and either provider or token is missing, // respond with a 2FA challenge payload. if (!hasProvider || !hasToken) { return twoFactorRequiredResponse('Two factor required.'); } let passedByRememberToken = false; if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_REMEMBER)) { if (deviceInfo.deviceIdentifier) { const trustedUserId = await storage.getTrustedTwoFactorDeviceTokenUserId( normalizedTwoFactorToken, deviceInfo.deviceIdentifier ); passedByRememberToken = trustedUserId === user.id; } // Remember token missing/invalid/expired should re-enter the 2FA challenge flow. if (!passedByRememberToken) { return twoFactorRequiredResponse('Two factor required.'); } } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) { const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken); if (!totpOk) { return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); } } else if ( normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE || normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) || normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST) ) { if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) { return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); } user.totpSecret = null; user.totpRecoveryCode = createRecoveryCode(); user.updatedAt = new Date().toISOString(); await storage.saveUser(user); await storage.deleteRefreshTokensByUserId(user.id); rememberRequested = false; } else { // Unsupported provider for this server profile behaves as an invalid 2FA attempt. return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); } // Upstream behavior: do not issue a new remember token when auth itself used remember provider. if (rememberRequested && !passedByRememberToken && deviceInfo.deviceIdentifier) { trustedTwoFactorTokenToReturn = createRefreshToken(); await storage.saveTrustedTwoFactorDeviceToken( trustedTwoFactorTokenToReturn, user.id, deviceInfo.deviceIdentifier, Date.now() + TWO_FACTOR_REMEMBER_TTL_MS ); } } // Persist device only after successful password + (optional) 2FA verification. const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); if (deviceSession) { await storage.upsertDevice( user.id, deviceSession.identifier, deviceInfo.deviceName, deviceInfo.deviceType, deviceSession.sessionStamp ); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); } // Successful login - clear failed attempts await rateLimit.clearLoginAttempts(loginIdentifier); if (validatedAuthRequestId) { await storage.markAuthRequestAuthenticated(validatedAuthRequestId); } const accessToken = await auth.generateAccessToken(user, deviceSession); const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const accountKeys = buildAccountKeys(user); const userDecryptionOptions = buildUserDecryptionOptions(user); await safeWriteAuditEvent(env, { actorUserId: user.id, action: 'auth.login.success', category: 'auth', level: 'info', targetType: 'user', targetId: user.id, metadata: { grantType, webSession: shouldUseWebSession(request), deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier, deviceType: deviceInfo.deviceType, ...auditRequestMetadata(request), }, }); const response: TokenResponse = { access_token: accessToken, expires_in: LIMITS.auth.accessTokenTtlSeconds, token_type: 'Bearer', ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }), ...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}), Key: user.key, PrivateKey: user.privateKey, AccountKeys: accountKeys, accountKeys: accountKeys, Kdf: user.kdfType, KdfIterations: user.kdfIterations, KdfMemory: user.kdfMemory, KdfParallelism: user.kdfParallelism, ForcePasswordReset: false, ResetMasterPassword: false, MasterPasswordPolicy: masterPasswordPolicyResponse(), ApiUseKeyConnector: false, scope: 'api offline_access', unofficialServer: true, UserDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions, }; const baseResponse = jsonResponse(response); return shouldUseWebSession(request) ? withWebRefreshCookie(request, baseResponse, refreshToken) : baseResponse; } else if (grantType === 'webauthn') { const loginIdentifier = clientIdentifier; const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); if (!loginCheck.allowed) { return identityErrorResponse( `Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`, 'TooManyRequests', 429 ); } const token = String(body.token || '').trim(); let deviceResponse: unknown = body.deviceResponse; if (typeof deviceResponse === 'string') { try { deviceResponse = JSON.parse(deviceResponse); } catch { return identityErrorResponse('Invalid passkey response', 'invalid_request', 400); } } if (!token || !deviceResponse) { return identityErrorResponse('Passkey token and deviceResponse are required', 'invalid_request', 400); } let asserted: Awaited>; try { asserted = await assertAccountPasskeyCredential(request, env, storage, { token, deviceResponse, scope: 'Authentication', }); } catch (error) { await rateLimit.recordFailedLogin(loginIdentifier); await safeWriteAuditEvent(env, { actorUserId: null, action: 'auth.passkey.login.failed', category: 'auth', level: 'warn', targetType: 'accountPasskey', targetId: null, metadata: { grantType, reason: error instanceof Error ? error.message : 'assertion_failed', ...auditRequestMetadata(request), }, }); return identityErrorResponse('Passkey is invalid. Try again', 'invalid_grant', 400); } const { user, credential } = asserted; if (user.status !== 'active') { await rateLimit.recordFailedLogin(loginIdentifier); return identityErrorResponse('Account is disabled', 'invalid_grant', 400); } const deviceInfo = readAuthRequestDeviceInfo(body, request); const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); if (deviceSession) { await storage.upsertDevice( user.id, deviceSession.identifier, deviceInfo.deviceName, deviceInfo.deviceType, deviceSession.sessionStamp ); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); } await rateLimit.clearLoginAttempts(loginIdentifier); const accessToken = await auth.generateAccessToken(user, deviceSession); const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const accountKeys = buildAccountKeys(user); const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential); const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption); await safeWriteAuditEvent(env, { actorUserId: user.id, action: 'auth.passkey.login.success', category: 'auth', level: 'info', targetType: 'accountPasskey', targetId: credential.id, metadata: { grantType, webSession: shouldUseWebSession(request), deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier, deviceType: deviceInfo.deviceType, ...auditRequestMetadata(request), }, }); const response: TokenResponse = { access_token: accessToken, expires_in: LIMITS.auth.accessTokenTtlSeconds, token_type: 'Bearer', ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }), Key: user.key, PrivateKey: user.privateKey, AccountKeys: accountKeys, accountKeys: accountKeys, Kdf: user.kdfType, KdfIterations: user.kdfIterations, KdfMemory: user.kdfMemory, KdfParallelism: user.kdfParallelism, ForcePasswordReset: false, ResetMasterPassword: false, MasterPasswordPolicy: masterPasswordPolicyResponse(), ApiUseKeyConnector: false, scope: 'api offline_access', unofficialServer: true, UserDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions, }; const baseResponse = jsonResponse(response); return shouldUseWebSession(request) ? withWebRefreshCookie(request, baseResponse, refreshToken) : baseResponse; } else if (grantType === 'client_credentials') { // Login with client credentials const clientId = body.client_id; const clientSecret = body.client_secret; const scope = body.scope; const deviceInfo = readAuthRequestDeviceInfo(body, request); const loginIdentifier = clientIdentifier; const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope); if (!parmValid) { return identityErrorResponse('Parameter error', 'invalid_request', 400); } // Check login lockout before user lookup to reduce user-enumeration signal const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); if (!loginCheck.allowed) { return identityErrorResponse( `Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`, 'TooManyRequests', 429 ); } const uid = clientId.slice(5); const user = await storage.getUserById(uid); if (!user) { await rateLimit.recordFailedLogin(loginIdentifier); return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400); } if (user.status !== 'active') { await rateLimit.recordFailedLogin(loginIdentifier); await safeWriteAuditEvent(env, { actorUserId: user.id, action: 'auth.login.failed.user_inactive', category: 'auth', level: 'warn', targetType: 'user', targetId: user.id, metadata: { grantType, deviceIdentifier: deviceInfo.deviceIdentifier, ...auditRequestMetadata(request), }, }); return identityErrorResponse('Account is disabled', 'invalid_grant', 400); } if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) { await rateLimit.recordFailedLogin(loginIdentifier); await safeWriteAuditEvent(env, { actorUserId: user.id, action: 'auth.login.failed.bad_api_key', category: 'auth', level: 'warn', targetType: 'user', targetId: user.id, metadata: { grantType, deviceIdentifier: deviceInfo.deviceIdentifier, ...auditRequestMetadata(request), }, }); return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400); } // Persist device only after successful client credential verification. const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); if (deviceSession) { await storage.upsertDevice( user.id, deviceSession.identifier, deviceInfo.deviceName, deviceInfo.deviceType, deviceSession.sessionStamp ); await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); } // Successful login - clear failed attempts await rateLimit.clearLoginAttempts(loginIdentifier); const accessToken = await auth.generateAccessToken(user, deviceSession); const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const accountKeys = buildAccountKeys(user); const userDecryptionOptions = buildUserDecryptionOptions(user); await safeWriteAuditEvent(env, { actorUserId: user.id, action: 'auth.login.success', category: 'auth', level: 'info', targetType: 'user', targetId: user.id, metadata: { grantType, webSession: shouldUseWebSession(request), deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier, deviceType: deviceInfo.deviceType, ...auditRequestMetadata(request), }, }); const response: TokenResponse = { access_token: accessToken, expires_in: LIMITS.auth.accessTokenTtlSeconds, token_type: 'Bearer', ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }), Key: user.key, PrivateKey: user.privateKey, AccountKeys: accountKeys, accountKeys: accountKeys, Kdf: user.kdfType, KdfIterations: user.kdfIterations, KdfMemory: user.kdfMemory, KdfParallelism: user.kdfParallelism, ForcePasswordReset: false, ResetMasterPassword: false, MasterPasswordPolicy: masterPasswordPolicyResponse(), ApiUseKeyConnector: false, scope: 'api offline_access', unofficialServer: true, UserDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions, }; const baseResponse = jsonResponse(response); return shouldUseWebSession(request) ? withWebRefreshCookie(request, baseResponse, refreshToken) : baseResponse; } else if (grantType === 'send_access') { const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute); if (!sendAccessLimit.allowed) { return identityErrorResponse( `Rate limit exceeded. Try again in ${sendAccessLimit.retryAfterSeconds} seconds.`, 'TooManyRequests', 429 ); } const sendId = String(body.send_id || body.sendId || '').trim(); if (!sendId) { return jsonResponse( { error: 'invalid_request', error_description: 'send_id is required', send_access_error_type: 'invalid_send_id', ErrorModel: { Message: 'send_id is required', Object: 'error', }, }, 400 ); } const passwordHashB64 = String( body.password_hash_b64 || body.passwordHashB64 || body.passwordHash || body.password_hash || '' ).trim() || null; const password = String(body.password || '').trim() || null; const result = await issueSendAccessToken( env, sendId, passwordHashB64, password, rateLimit, `${clientIdentifier}:send-password` ); if ('error' in result) { return result.error; } return jsonResponse({ access_token: result.token, expires_in: LIMITS.auth.sendAccessTokenTtlSeconds, token_type: 'Bearer', scope: 'api.send', unofficialServer: true, }); } else if (grantType === 'refresh_token') { const refreshLimit = await rateLimit.consumeBudget( `${clientIdentifier}:identity-refresh`, LIMITS.rateLimit.refreshTokenRequestsPerMinute ); if (!refreshLimit.allowed) { return identityErrorResponse( `Rate limit exceeded. Try again in ${refreshLimit.retryAfterSeconds} seconds.`, 'TooManyRequests', 429 ); } // Refresh token const refreshToken = String(body.refresh_token || '').trim() || ( shouldUseWebSession(request) ? parseCookieValue(request, WEB_REFRESH_COOKIE) : null ); if (!refreshToken) { return identityErrorResponse('Refresh token is required', 'invalid_request', 400); } const result = await auth.refreshAccessTokenDetailed(refreshToken); if (!result.ok) { await safeWriteAuditEvent(env, { actorUserId: result.userId ?? null, action: `auth.refresh.failed.${result.reason}`, category: 'auth', level: 'warn', targetType: result.deviceIdentifier ? 'device' : 'refreshToken', targetId: result.deviceIdentifier ?? null, metadata: { grantType, reason: result.reason, webSession: shouldUseWebSession(request), ...auditRequestMetadata(request), }, }); const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400); return shouldUseWebSession(request) ? withWebRefreshCookie(request, invalidResponse, null) : invalidResponse; } // Keep a short overlap window for old refresh token to absorb // concurrent refresh requests from multiple client contexts. await storage.constrainRefreshTokenExpiry( refreshToken, Date.now() + LIMITS.auth.refreshTokenOverlapGraceMs ); const { accessToken, user, device } = result; if (device?.identifier) { await storage.touchDeviceLastSeen(user.id, device.identifier); } const newRefreshToken = await auth.generateRefreshToken(user.id, device); const accountKeys = buildAccountKeys(user); const userDecryptionOptions = buildUserDecryptionOptions(user); const response: TokenResponse = { access_token: accessToken, expires_in: LIMITS.auth.accessTokenTtlSeconds, token_type: 'Bearer', ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }), Key: user.key, PrivateKey: user.privateKey, AccountKeys: accountKeys, accountKeys: accountKeys, Kdf: user.kdfType, KdfIterations: user.kdfIterations, KdfMemory: user.kdfMemory, KdfParallelism: user.kdfParallelism, ForcePasswordReset: false, ResetMasterPassword: false, MasterPasswordPolicy: masterPasswordPolicyResponse(), ApiUseKeyConnector: false, scope: 'api offline_access', unofficialServer: true, UserDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions, }; const baseResponse = jsonResponse(response); return shouldUseWebSession(request) ? withWebRefreshCookie(request, baseResponse, newRefreshToken) : baseResponse; } return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400); } // POST /identity/accounts/prelogin export async function handlePrelogin(request: Request, env: Env): Promise { const storage = new StorageService(env.DB); let body: { email?: string }; try { body = await request.json(); } catch { return errorResponse('Invalid JSON', 400); } const email = body.email?.toLowerCase(); if (!email) { return errorResponse('Email is required', 400); } const user = await storage.getUser(email); // Return default KDF settings even if user doesn't exist (to prevent user enumeration) const kdfType = user?.kdfType ?? 0; const kdfIterations = user?.kdfIterations ?? LIMITS.auth.defaultKdfIterations; // Use ?? null so non-existent users return null (not undefined/omitted) for these fields, // matching the response shape of real PBKDF2 users and reducing enumeration signal. const kdfMemory = user?.kdfMemory ?? null; const kdfParallelism = user?.kdfParallelism ?? null; return jsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism)); } // POST /identity/connect/revocation // Best-effort OAuth token revocation endpoint. // RFC 7009 allows returning 200 even if token is unknown. export async function handleRevocation(request: Request, env: Env): Promise { const storage = new StorageService(env.DB); let body: Record; const contentType = request.headers.get('content-type') || ''; try { if (contentType.includes('application/x-www-form-urlencoded')) { const formData = await request.formData(); body = Object.fromEntries(formData.entries()) as Record; } else { body = await request.json(); } } catch { return new Response(null, { status: 200 }); } const token = String(body.token || '').trim() || ( shouldUseWebSession(request) ? (parseCookieValue(request, WEB_REFRESH_COOKIE) || '') : '' ); if (token) { await storage.deleteRefreshToken(token); } const baseResponse = new Response(null, { status: 200 }); return shouldUseWebSession(request) ? withWebRefreshCookie(request, baseResponse, null) : baseResponse; } export function checkClientCredentialsParam(clientId: string, clientSecret: string, scope: string): boolean { if (scope !== 'api') { return false; } if (!clientId.startsWith('user.')) { return false; } if (!clientSecret) { return false; } return true; }