toeverything/AFFiNE · error · ActionForbidden

action_forbidden

action_forbidden

Error message

You are not allowed to perform this action.

What it means

MagicLinkAuthService.send rejects the supplied callbackUrl because URLHelper.isAllowedCallbackUrl returns false. A callback URL is allowed only if it is a same-app relative path (starts with a single '/') or an absolute URL whose origin is in the server's configured allowedOrigins, uses an allowed protocol, and carries no userinfo. Thrown as ActionForbidden (action_forbidden category) before any user lookup or email is sent, so it is a request-shape / redirect-safety failure, not an auth failure.

Source

Thrown at packages/backend/server/src/core/auth/magic-link.ts:42

  constructor(
    private readonly url: URLHelper,
    private readonly auth: AuthService,
    private readonly models: Models,
    private readonly config: Config,
    private readonly crypto: CryptoHelper
  ) {}

  async send(
    email: string,
    callbackUrl = '/magic-link',
    clientNonce?: string,
    metadata?: Pick<MailDeliveryMetadata, 'source'>
  ) {
    validators.assertValidEmail(email);

    if (!this.url.isAllowedCallbackUrl(callbackUrl)) {
      throw new ActionForbidden();
    }

    const callbackUrlObj = this.url.url(callbackUrl);
    const redirectUriInCallback =
      callbackUrlObj.searchParams.get('redirect_uri');
    if (
      redirectUriInCallback &&
      !this.url.isAllowedRedirectUri(redirectUriInCallback)
    ) {
      throw new ActionForbidden();
    }

    const user = await this.models.user.getUserByEmail(email, {
      withDisabled: true,
    });

    if (!user) {
      await this.assertSignupAllowed(email);

View on GitHub (pinned to 26c515e050)

Solutions

  1. Use a same-origin relative callbackUrl such as '/magic-link' so the check passes unconditionally.
  2. Add the callback origin to the server's allowed origins config (AFFiNE_SERVER_URL / url allowedOrigins) and redeploy.
  3. Verify the URL has no embedded credentials and uses http/https.
  4. Validate the callbackUrl client-side against the configured origin before invoking the API.

Example fix

// before
magicLink.send(email, 'https://other-app.example/cb')
// after — relative path, always allowed
magicLink.send(email, '/magic-link')
Defensive patterns

Strategy: validation

Validate before calling

function buildCallbackUrl(path: string): string {
  // only ever pass same-origin relative paths
  if (!path.startsWith('/') || path.startsWith('//')) {
    throw new Error('callbackUrl must be a same-origin relative path');
  }
  return path;
}

await magicLink.send(email, buildCallbackUrl('/magic-link'));

Prevention

When it happens

Trigger: Calling the magic-link send API with a callbackUrl that is empty, uses a protocol not in ALLOWED_REDIRECT_PROTOCOLS, includes credentials (user:pass@), is a protocol-relative URL ('//evil'), or points to an origin not listed in the server's allowedOrigins / URL_SAFE_PATTERN config.

Common situations: Frontend points callbackUrl at a different domain than the one the server was deployed with (mismatched AFFiNE_SERVER_URL / allowed origins). A developer passed a fully external callback URL in local dev without configuring origins. A malicious or copy-pasted link contains a protocol-relative or javascript: URL.

Related errors


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