toeverything/AFFiNE · error · ActionForbidden

action_forbidden

action_forbidden

Error message

You are not allowed to perform this action.

What it means

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.

Solutions

  1. Browsers must use the standard web sign-in/session flow, not session-exchange
  2. Native clients must send the identification isNativeClientRequest looks for (client headers/user-agent) on every exchange call
  3. Check intermediate proxies/gateways are not rewriting or dropping those headers in dev

Example fix

// before
await fetch('/api/auth/session-exchange', {
  method: 'POST',
  body: JSON.stringify({ code }),
});

// after
// web app: use the web sign-in flow instead
await webSignIn();

// native app: ensure the native client identification is attached
await nativeHttpClient.exchange(code, metadata); // sets native client headers/UA
Defensive patterns

Strategy: validation

Validate before calling

function canUseSessionExchange(): boolean {
  // mirror isNativeClientRequest: only the native app qualifies
  return isNativeRuntime(); // e.g. react-native / capacitor flag, not a browser
}
if (!canUseSessionExchange()) {
  await webSignIn();
} else {
  await nativeClient.exchange(code, metadata);
}

Type guard

function isActionForbidden(e: unknown): boolean {
  return (
    typeof e === 'object' &&
    e !== null &&
    'code' in e &&
    (e as { code?: string }).code === 'action_forbidden'
  );
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18). Data as JSON: /api/errors/88a3973719027b85. Report an issue: GitHub.

Appendix: source

Thrown at packages/backend/server/src/core/auth/session-exchange.ts:82

    private readonly challenges: AuthChallengeStore,
    private readonly cache: Cache,
    private readonly models: Models,
    private readonly accessTokens: AccessTokenService,
    private readonly authSessions: AuthSessionService
  ) {}

  async createCode(req: Request, userId: string, clientVersion?: string) {
    if (!isNativeClientRequest(req)) return;
    return this.challenges.create<SessionExchangePayload>(
      'auth_session_exchange',
      { userId, clientVersion },
      60 * 1000
    );
  }

  @Transactional()
  async exchange(req: Request, code: string, metadata: AuthSessionMetadata) {
    if (!isNativeClientRequest(req)) throw new ActionForbidden();
    const payload = await this.challenges.consume<SessionExchangePayload>(
      'auth_session_exchange',
      code
    );
    if (!payload?.userId) throw new InvalidAuthState();
    const user = await this.models.user.lockForAuthIssuance(payload.userId);
    if (!user || user.disabled) throw new InvalidAuthState();
    const userSession = await this.auth.createUserSession(
      payload.userId,
      undefined,
      undefined,
      payload.clientVersion
    );

    const issued = await this.authSessions.create({
      userSessionId: userSession.id,
      ...metadata,
    });

View on GitHub (pinned to b4c8548c09)