{"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/b4c8548c09da21b2898443559a5b846f0ccf5dd8/packages/backend/server/src/core/auth/session-exchange.ts#L64-L100","documentation":"SessionExchangeService.exchange is the native-client half of a web-to-native login handoff: the web app creates a one-time code, the native app exchanges it for tokens. exchange() rejects any request that isNativeClientRequest(req) does not recognize as coming from the native client, throwing ActionForbidden.","triggerScenarios":"POSTing to the session-exchange exchange endpoint from a browser, curl, or server-side script — anything lacking the native client request markers (client identifier headers/user-agent) the check requires.","commonSituations":"Web frontend mistakenly calls the native exchange endpoint instead of normal web sign-in; a dev proxy or test harness strips the identifying headers/user-agent; API client reused across platforms without setting native identification.","solutions":["Browsers must use the standard web sign-in/session flow, not session-exchange","Native clients must send the identification isNativeClientRequest looks for (client headers/user-agent) on every exchange call","Check intermediate proxies/gateways are not rewriting or dropping those headers in dev"],"exampleFix":"// before\nawait fetch('/api/auth/session-exchange', {\n  method: 'POST',\n  body: JSON.stringify({ code }),\n});\n\n// after\n// web app: use the web sign-in flow instead\nawait webSignIn();\n\n// native app: ensure the native client identification is attached\nawait nativeHttpClient.exchange(code, metadata); // sets native client headers/UA","handlingStrategy":"validation","validationCode":"function canUseSessionExchange(): boolean {\n  // mirror isNativeClientRequest: only the native app qualifies\n  return isNativeRuntime(); // e.g. react-native / capacitor flag, not a browser\n}\nif (!canUseSessionExchange()) {\n  await webSignIn();\n} else {\n  await nativeClient.exchange(code, metadata);\n}","typeGuard":"function isActionForbidden(e: unknown): boolean {\n  return (\n    typeof e === 'object' &&\n    e !== null &&\n    'code' in e &&\n    (e as { code?: string }).code === 'action_forbidden'\n  );\n}","tryCatchPattern":null,"preventionTips":["Keep web and native auth transports separate per platform build","Ensure native requests carry their client identification through every proxy","Point integration tests at the correct flow per client type"],"tags":["auth","native-client","session","http"],"backgroundTag":"client-type-rejected","analyzedSha":"b4c8548c09da21b2898443559a5b846f0ccf5dd8","analyzedAt":"2026-08-18T21:16:52.546Z","contentChangedAt":"2026-08-18T21:16:52.546Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}