toeverything/AFFiNE · error · ActionForbidden

action_forbidden

action_forbidden

Error message

You are not allowed to perform this action.

What it means

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.

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 26c515e050)

Solutions

  1. If the caller is a web app, use the web (cookie/session) auth flow instead of the native exchange endpoint.
  2. If the caller is a native SDK, ensure it sends the required CLIENT_KIND_HEADER and a non-browser Origin on every request.
  3. In tests, set the headers via the NativeClientHeadersSchema shape or use the provided test request builder.

Example fix

// before: calling exchange from a browser fetch
fetch('/auth/session/exchange', { method: 'POST', body: JSON.stringify({ code }) });

// after: native client sets the required headers
fetch('/auth/session/exchange', {
  method: 'POST',
  headers: { [CLIENT_KIND_HEADER]: 'ios', 'Content-Type': 'application/json' },
  body: JSON.stringify({ code, metadata }),
});
Defensive patterns

Strategy: validation

Validate before calling

import { NativeClientHeadersSchema, CLIENT_KIND_HEADER } from '../../core/auth/input';

function buildNativeHeaders(clientKind: string): Record<string, string> {
  const ok = NativeClientHeadersSchema.safeParse({ clientKind, origin: 'native' }).success;
  if (!ok) throw new Error('Not a native client request');
  return { [CLIENT_KIND_HEADER]: clientKind };
}

Type guard

function isNativeClientHeaders(input: unknown): boolean {
  return NativeClientHeadersSchema.safeParse(input).success;
}

Try / catch

try {
  await exchange(req, code, metadata);
} catch (e) {
  if (e.code === 'action_forbidden' && !isNativeClientRequest(req)) {
    throw new Error('Use the web session flow from browsers; exchange is native-only');
  }
  throw e;
}

Prevention

When it happens

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

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

Related errors


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