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