{"record":{"id":"88a3973719027b85","repo":"toeverything/AFFiNE","slug":"action-forbidden-88a397","errorCode":"action_forbidden","errorMessage":"You are not allowed to perform this action.","messagePattern":"You are not allowed to perform this action\\.","errorType":"exception","errorClass":"ActionForbidden","httpStatus":403,"severity":"error","filePath":"packages/backend/server/src/core/auth/session-exchange.ts","lineNumber":82,"sourceCode":"    private readonly challenges: AuthChallengeStore,\n    private readonly cache: Cache,\n    private readonly models: Models,\n    private readonly accessTokens: AccessTokenService,\n    private readonly authSessions: AuthSessionService\n  ) {}\n\n  async createCode(req: Request, userId: string, clientVersion?: string) {\n    if (!isNativeClientRequest(req)) return;\n    return this.challenges.create<SessionExchangePayload>(\n      'auth_session_exchange',\n      { userId, clientVersion },\n      60 * 1000\n    );\n  }\n\n  @Transactional()\n  async exchange(req: Request, code: string, metadata: AuthSessionMetadata) {\n    if (!isNativeClientRequest(req)) throw new ActionForbidden();\n    const payload = await this.challenges.consume<SessionExchangePayload>(\n      'auth_session_exchange',\n      code\n    );\n    if (!payload?.userId) throw new InvalidAuthState();\n    const user = await this.models.user.lockForAuthIssuance(payload.userId);\n    if (!user || user.disabled) throw new InvalidAuthState();\n    const userSession = await this.auth.createUserSession(\n      payload.userId,\n      undefined,\n      undefined,\n      payload.clientVersion\n    );\n\n    const issued = await this.authSessions.create({\n      userSessionId: userSession.id,\n      ...metadata,\n    });","sourceCodeStart":64,"sourceCodeEnd":100,"githubUrl":"https://github.com/toeverything/AFFiNE/blob/26c515e050211269e911f7d9cfe162a26c83ed98/packages/backend/server/src/core/auth/session-exchange.ts#L64-L100","documentation":"Thrown by SessionExchangeService.exchange when isNativeClientRequest(req) is false. Category 'action_forbidden', code 'action_forbidden'. The exchange endpoint (which turns a one-time code into access/refresh tokens) is reserved for native clients (iOS/Android/Electron) identified by their specific client-kind header + origin; a web/browser request is rejected before any work begins.","triggerScenarios":"Calling the session-exchange `exchange` operation from a browser, curl, or any HTTP client whose request headers do not satisfy NativeClientHeadersSchema ({ clientKind, origin }). The guard at session-exchange.ts:82 fires before the challenge code is consumed.","commonSituations":"A web frontend mistakenly calling the native token-exchange endpoint instead of the web session flow; integration tests that omit the native client headers; a reverse proxy stripping the CLIENT_KIND_HEADER or Origin header.","solutions":["If the caller is a web app, use the web (cookie/session) auth flow instead of the native exchange endpoint.","If the caller is a native SDK, ensure it sends the required CLIENT_KIND_HEADER and a non-browser Origin on every request.","In tests, set the headers via the NativeClientHeadersSchema shape or use the provided test request builder."],"exampleFix":"// before: calling exchange from a browser fetch\nfetch('/auth/session/exchange', { method: 'POST', body: JSON.stringify({ code }) });\n\n// after: native client sets the required headers\nfetch('/auth/session/exchange', {\n  method: 'POST',\n  headers: { [CLIENT_KIND_HEADER]: 'ios', 'Content-Type': 'application/json' },\n  body: JSON.stringify({ code, metadata }),\n});","handlingStrategy":"validation","validationCode":"import { NativeClientHeadersSchema, CLIENT_KIND_HEADER } from '../../core/auth/input';\n\nfunction buildNativeHeaders(clientKind: string): Record<string, string> {\n  const ok = NativeClientHeadersSchema.safeParse({ clientKind, origin: 'native' }).success;\n  if (!ok) throw new Error('Not a native client request');\n  return { [CLIENT_KIND_HEADER]: clientKind };\n}","typeGuard":"function isNativeClientHeaders(input: unknown): boolean {\n  return NativeClientHeadersSchema.safeParse(input).success;\n}","tryCatchPattern":"try {\n  await exchange(req, code, metadata);\n} catch (e) {\n  if (e.code === 'action_forbidden' && !isNativeClientRequest(req)) {\n    throw new Error('Use the web session flow from browsers; exchange is native-only');\n  }\n  throw e;\n}","preventionTips":["Keep native and web auth flows strictly separated at the client.","Ensure the native SDK always sends CLIENT_KIND_HEADER and Origin.","Configure ingress to preserve both headers without rewriting."],"tags":["auth","session","native-client","forbidden","headers"],"backgroundTag":null,"analyzedSha":"26c515e050211269e911f7d9cfe162a26c83ed98","analyzedAt":"2026-08-12T13:15:16.447Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}