SPA Authentication Guide

This guide covers OAuth2 authentication for Single Page Applications (SPAs) using ActingWeb. SPAs require special handling because they run entirely in the browser and cannot securely store client secrets.

Understanding ActingWeb’s Two OAuth2 Roles

ActingWeb operates in two distinct OAuth2 roles that use different endpoints:

1. ActingWeb as OAuth2 Client (External Login)

When users log in via Google, GitHub, or other external providers:

  • ActingWeb acts as the OAuth2 client

  • Google/GitHub are the OAuth2 servers (authorization servers)

  • User authenticates WITH Google/GitHub, then ActingWeb creates/updates an actor

Endpoints:

  • /oauth/spa/authorize - Initiate login with Google/GitHub

  • /oauth/spa/token - Refresh tokens from external provider

  • /oauth/callback - Receive authorization code from Google/GitHub

2. ActingWeb as OAuth2 Server (MCP Authentication)

When MCP clients (ChatGPT, Claude, Cursor) connect to ActingWeb:

  • ActingWeb acts as the OAuth2 server (authorization server)

  • MCP clients are the OAuth2 clients

  • MCP client authenticates TO ActingWeb to access tools/resources

Endpoints:

  • /oauth/authorize - MCP client requests authorization

  • /oauth/token - MCP client exchanges code for token

  • /oauth/register - MCP client dynamic registration

Why This Matters

The /oauth/authorize and /oauth/spa/authorize endpoints look similar but serve completely different purposes:

Aspect

/oauth/spa/authorize

/oauth/authorize

ActingWeb Role

OAuth2 Client

OAuth2 Server

Purpose

User logs in via Google/GitHub

MCP client authenticates to ActingWeb

Who authenticates

User → Google/GitHub

MCP client → ActingWeb

Result

Actor created/updated in ActingWeb

MCP client gets access token

Overview

ActingWeb provides dedicated SPA OAuth2 endpoints that:

  • Return pure JSON responses (no HTML templates)

  • Support server-managed PKCE (Proof Key for Code Exchange)

  • Offer multiple token delivery modes (JSON, cookies, hybrid)

  • Implement refresh token rotation for security

  • Include CORS headers for cross-origin requests

SPA Route Requirements

When building an SPA with ActingWeb, configure your app with with_web_ui(False):

app = ActingWebApp(...)
    .with_web_ui(enable=False)  # Disable server templates, use SPA mode
    .with_oauth(...)

Your application must provide these routes:

  1. ``/login`` - SPA login page with OAuth buttons

  2. ``/<actor_id>/app`` - Main SPA entry point for authenticated users

Browser Redirect Behavior

ActingWeb automatically handles browser redirects for SPAs:

Scenario

Redirect Target

Unauthenticated browser → /<actor_id>

/login

After OAuth login completes

/<actor_id>/app

Authenticated browser → /<actor_id>

/<actor_id>/app

This eliminates the need for custom route handlers to redirect browsers. Your SPA only needs to:

  1. Serve the login page at /login

  2. Serve the SPA shell at /<actor_id>/app

  3. Handle authentication state in JavaScript

API clients (sending Accept: application/json) always receive JSON responses, not redirects.

Token Architecture

Important: ActingWeb generates its own session tokens rather than passing through OAuth provider tokens (Google/GitHub) directly. This provides several benefits:

  1. Performance: No network calls to validate tokens on every API request

  2. Reliability: No dependency on OAuth provider availability after initial auth

  3. Security: OAuth provider tokens never exposed to frontend JavaScript

  4. Control: Custom token expiry, permissions, and rotation policies

How It Works:

┌─────────────────────────────────────────────────────────────┐
│                    OAuth Callback                           │
│  1. Validate Google/GitHub token (once)                     │
│  2. Generate ActingWeb access token                         │
│  3. Store token in session manager                          │
│  4. Return ActingWeb token (not Google token)               │
└─────────────────────────────────────────────────────────────┘
                           │
      ┌────────────────────┴────────────────────┐
      ▼                                         ▼
┌─────────────────────┐                 ┌─────────────────────┐
│        SPA          │                 │       /www          │
│  Token in memory    │                 │  Token in cookie    │
│  Auth header        │                 │  HttpOnly cookie    │
└─────────────────────┘                 └─────────────────────┘
      │                                         │
      └────────────────────┬────────────────────┘
                           ▼
┌─────────────────────────────────────────────────────────────┐
│                    Request Validation                       │
│  - Session manager lookup (fast, no network)                │
│  - Falls back to OAuth provider validation (legacy)         │
└─────────────────────────────────────────────────────────────┘

Token Lifecycle:

  • Access tokens: 1-hour TTL, stored in session manager

  • Refresh tokens: 2-week TTL, supports rotation

  • Both token types are validated against ActingWeb’s session manager, not OAuth providers

This architecture applies to both SPAs (tokens in memory) and traditional /www apps (tokens in HttpOnly cookies).

Key Endpoints

Most OAuth endpoints are unified at /oauth/* and work for both SPAs and traditional web apps. Only /oauth/spa/authorize and /oauth/spa/token remain separate because they serve a different OAuth role (ActingWeb as OAuth client to Google/GitHub) than the MCP OAuth2 endpoints (ActingWeb as OAuth server).

Endpoint

Method

Description

/oauth/config

GET

Get OAuth configuration and available providers

/oauth/spa/authorize

POST

Initiate external OAuth flow (ActingWeb as OAuth client)

/oauth/callback

GET

Handle OAuth callback (auto-detects SPA mode via state param)

/oauth/spa/token

POST

Token refresh with rotation for external provider tokens

/oauth/revoke

POST

Revoke a token and the refresh-token chain it belongs to

/oauth/session

GET

Check current session status

/oauth/logout

POST/GET

Revoke the session’s refresh-token chain and clear its cookies (returns JSON when Accept: application/json)

Getting Started

1. Get OAuth Configuration

Before initiating login, fetch the available OAuth providers:

const response = await fetch('/oauth/config');
const config = await response.json();

// config structure:
// {
//   "oauth_enabled": true,
//   "oauth_providers": [
//     {
//       "name": "google",
//       "display_name": "Google",
//       "authorization_endpoint": "..."
//     },
//     {
//       "name": "github",
//       "display_name": "GitHub",
//       "authorization_endpoint": "..."
//     }
//   ],
//   "pkce_supported": true,
//   "token_delivery_modes": ["json", "cookie", "hybrid"],
//   "endpoints": {...}
// }
//
// Note: trust_types are NOT included here. Trust types are only
// relevant for MCP client authorization (ActingWeb as OAuth server),
// not for user login (ActingWeb as OAuth client).

2. Initiate OAuth Flow

Start the OAuth flow with optional server-managed PKCE.

For User Login (no trust relationship):

const authResponse = await fetch('/oauth/spa/authorize', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        provider: 'google',
        // NO trust_type = simple user login
        redirect_uri: window.location.origin + '/callback',
        pkce: 'server',  // Let server manage PKCE
        token_delivery: 'json',  // Return tokens in JSON
        return_path: '/app'  // Where to redirect after auth (default: /app)
    })
});

const auth = await authResponse.json();

// Redirect to OAuth provider
window.location.href = auth.authorization_url;

The return_path parameter specifies where to redirect after successful authentication. It will be prepended with the actor ID: /{actor_id}{return_path}. You can also use the {actor_id} placeholder for custom paths: return_path: '/{actor_id}/dashboard'.

For MCP Client Authorization (creates trust relationship):

If your SPA is an MCP client that needs to establish a trust relationship with a specific permission level, include the trust_type parameter:

const authResponse = await fetch('/oauth/spa/authorize', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        provider: 'google',
        trust_type: 'mcp_client',  // Creates trust relationship with this type
        redirect_uri: window.location.origin + '/callback',
        pkce: 'server',
        token_delivery: 'json'
    })
});

Note

trust_type parameter:

  • Omitted or null: Simple user login. Creates/looks up actor, no trust relationship.

  • Specified (e.g., “mcp_client”): MCP authorization. Creates actor AND trust relationship with the specified permission level.

3. Handle Callback

The OAuth callback flow works in two stages for SPAs:

Stage 1: Browser Redirect from OAuth Provider

After the user authenticates with Google/GitHub, the OAuth provider redirects the browser to /oauth/callback. When the server detects SPA mode (via spa_mode: true in state), it redirects the browser to your SPA’s redirect_uri (e.g., /callback) with the authorization code and state preserved:

Google → /oauth/callback?code=xxx&state={"spa_mode":true,...}
               ↓ (server detects SPA mode, redirects)
       /callback?code=xxx&state={"spa_mode":true,...}

Stage 2: SPA Exchanges Code for Tokens

Your SPA callback page then calls the server to exchange the code for tokens:

// On /callback page
const params = new URLSearchParams(window.location.search);

// Call /oauth/callback with Accept: application/json to get tokens
const tokens = await fetch('/oauth/callback?' + params.toString(), {
    headers: { 'Accept': 'application/json' }  // Required for JSON response
}).then(r => r.json());

if (tokens.success) {
    // Store access token (in memory for security)
    setAccessToken(tokens.access_token);

    // Navigate to app - redirect_url contains the return_path
    window.location.href = tokens.redirect_url;  // e.g., /abc123/app
}

Note

The Accept: application/json header is required in Stage 2. Without it, the server will perform another redirect (Stage 1 behavior).

Email Verification for SPA

When an OAuth provider (e.g., GitHub with private email) cannot provide a verified email address, the callback redirects back to the SPA with special parameters instead of redirecting to a server-rendered HTML form:

/oauth/callback → detects email_required
    ↓ (redirects back to SPA)
{spa_redirect_url}?email_required=true&session={session_id}

Your SPA should detect the email_required parameter and show an email input form:

// On /callback page
const params = new URLSearchParams(window.location.search);

if (params.get('email_required') === 'true') {
    const sessionId = params.get('session');
    // Show email input form in SPA
    showEmailForm(sessionId);
    return;
}

When the user submits their email, POST it to /oauth/email:

const res = await fetch('/oauth/email', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json'
    },
    body: JSON.stringify({
        session: sessionId,
        email: userEmail
    })
});
const response = await res.json();

if (response.success) {
    if (response.email_requires_verification) {
        // Email needs verification — the email_verification_required
        // lifecycle hook has already fired and your backend sends the
        // verification email. Show the user a "check your inbox" message.
        showVerificationPending(response.email);
    } else {
        // Email was from the provider's verified list — no verification needed
        setAccessToken(response.access_token);
        window.location.href = response.redirect_url;
    }
} else if (response.code === 'actor_exists') {
    // 409 — a free-text address already has an actor. The pending
    // session was consumed; the user must sign in again to try a
    // different address.
    showError('An account already exists for that address. Please sign in again.');
} else if (response.code === 'authentication_rejected') {
    // 403 — the oauth_success lifecycle hook rejected this login.
    showError('Authentication was rejected.');
} else {
    // Any other error carries {error: true, status_code, message}.
    showError(response.message || 'Something went wrong. Please try again.');
}

The verification email is sent by your application’s email_verification_required lifecycle hook — the same hook used for HTML template applications:

@app.lifecycle_hook("email_verification_required")
def send_verification_email(actor, email, verification_url, token):
    # verification_url is: https://<root>/oauth/email?verify=<token>
    send_email(
        to=email,
        subject="Verify your email",
        body=f"Click here to verify: {verification_url}"
    )

When the user clicks the verification link, the browser navigates to GET /oauth/email?verify=<token>, which validates the token and marks the email as verified. See ActingWeb Authentication System for the full verification flow details.

Token Delivery Modes

ActingWeb supports three token delivery modes to accommodate different security requirements:

JSON Mode (Default)

Returns all tokens in the JSON response. Best for:

  • SPAs that store tokens in memory

  • Development and testing

  • Maximum flexibility

{
    "success": true,
    "access_token": "eyJhbGciOiJIUzI1NiIs...",
    "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
    "token_type": "Bearer",
    "expires_in": 3600,
    "actor_id": "abc123"
}

Hybrid Mode

Access token in JSON, refresh token in HttpOnly cookie. Best for:

  • Balance of security and convenience

  • SPAs that need immediate access to access tokens

  • Protecting long-lived refresh tokens from XSS

// Request with hybrid mode
const auth = await fetch('/oauth/spa/authorize', {
    method: 'POST',
    body: JSON.stringify({
        provider: 'google',
        token_delivery: 'hybrid'
    })
});

// Response - access token in body, refresh in cookie
// {
//     "success": true,
//     "access_token": "eyJhbGciOiJIUzI1NiIs...",
//     "token_type": "Bearer",
//     "expires_in": 3600,
//     "actor_id": "abc123",
//     "token_delivery": "hybrid"
// }

Note

Logging out of a hybrid session started by the provider callback. When the OAuth callback itself delivers a hybrid session, its refresh-token cookie is scoped to /oauth/spa/token and the browser never sends it to /oauth/logout. The access token still ends the whole chain while it is valid, and a client that holds the refresh token can send it in the logout body. A request the browser sends cross-site with SameSite=Lax cookies does not carry the cookie either. See Logout.

PKCE Support

PKCE (Proof Key for Code Exchange) is essential for SPAs because they cannot securely store client secrets. ActingWeb supports two PKCE modes:

Client-Managed PKCE

Generate PKCE client-side for full control:

// Generate PKCE client-side
function generateCodeVerifier() {
    const array = new Uint8Array(64);
    crypto.getRandomValues(array);
    return btoa(String.fromCharCode.apply(null, array))
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=/g, '');
}

async function generateCodeChallenge(verifier) {
    const encoder = new TextEncoder();
    const data = encoder.encode(verifier);
    const digest = await crypto.subtle.digest('SHA-256', data);
    return btoa(String.fromCharCode.apply(null, new Uint8Array(digest)))
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=/g, '');
}

// Store verifier in session
const verifier = generateCodeVerifier();
sessionStorage.setItem('pkce_verifier', verifier);

const challenge = await generateCodeChallenge(verifier);

// Send challenge to server
const auth = await fetch('/oauth/spa/authorize', {
    method: 'POST',
    body: JSON.stringify({
        provider: 'google',
        pkce: 'client',
        code_challenge: challenge,
        code_challenge_method: 'S256'
    })
});

Native id_token (JWT-bearer) grant

Native mobile apps that already hold a provider id_token (Apple via the iOS plugin, Google via the native SDK) exchange it directly for an ActingWeb session through the RFC 7523 JWT-bearer grant — no authorization code, no userinfo call:

async function nativeSignIn(provider, idToken, nonce) {
    const response = await fetch('/oauth/spa/token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
            provider: provider,        // "apple-mobile" or "google-native"
            assertion: idToken,        // the provider id_token (JWT)
            nonce: nonce,              // nonce the app sent to the IdP
            token_delivery: 'json'
        })
    });
    return response.json();
}

The server selects the validator by the declared provider and rejects the request if the token iss does not match it; nonce is required, and each id_token is single-use (replay-protected).

Mobile-ticket grant (server-side code exchange)

Providers that do not hand the app a usable id_token — Sign-in-with-Apple on Android (Apple requires an HTTPS redirect_uri) and GitHub (no OIDC id_token at all) — route their authorization response through the HTTPS /oauth/callback. The server stores the authorization code and deep-links the app with only an opaque single-use ticket; the code is exchanged server-side. Neither the code nor any ActingWeb token ever rides the deep link:

// ticket arrives via the app's custom-scheme deep link
await fetch('/oauth/spa/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ grant_type: 'mobile_ticket', ticket })
});

The grant is provider-agnostic: the server derives identity the way each provider supports it (Apple’s id_token in the token response, GitHub’s userinfo endpoint). apple_mobile_ticket remains accepted as an alias for the original Apple-only name.

Ticket security guarantees (downstreams may rely on these):

  • Single-use: the first successful redemption atomically consumes the ticket. Any later redemption of the same ticket fails with 400 (invalid_grant) and issues no tokens. A relaunch that re-delivers the same deep-link URL (e.g. App.getLaunchUrl() after a force-quit) therefore cannot mint a second session.

  • Atomic / race-free: consumption is gated on an atomic conditional delete, so two concurrent /oauth/spa/token calls with the same ticket result in at most one success — a replay race cannot mint two sessions.

  • Short TTL: redemption is rejected once the ticket is older than 300 seconds (5 minutes), enforced at consume time, so a leaked or stale ticket cannot be redeemed later. (The stored row is purged separately by the database TTL — which carries a clock-skew buffer and is only a cleanup backstop — but an out-of-window ticket is refused regardless of when that purge runs.)

A client-side de-dupe guard (in-memory plus a persisted “last handled ticket” marker) is still worthwhile to avoid a needless rejected round-trip, but the single-use property is enforced server-side and does not depend on it.

Configure these providers with app.with_apple_sign_in(...), app.with_google_native(...) and app.with_github(..., mobile_redirect_uri=...). See Sign in with Apple for the full Apple setup.

Note

PKCE is mandatory for native authorization_code exchanges. If an app uses the plain authorization_code grant (below) with a -mobile/-native provider or a custom-scheme redirect_uri, it must include a code_verifier (RFC 8252) or the request is rejected with 400. The ticket flow above is preferred precisely because the code never reaches the device. Web/SPA same-origin exchanges (server-managed PKCE) are unaffected.

Migration note: apps that previously implemented a custom native sign-in endpoint (e.g. a hand-rolled /api/auth/google-mobile verifying an id_token) should switch to this library-provided JWT-bearer grant.

Token Refresh with Rotation

ActingWeb implements refresh token rotation for enhanced security:

  • Each refresh token can only be used once

  • Token exchange returns both new access AND refresh tokens

  • Reusing an old refresh token (beyond the concurrency grace window) is treated as theft and revokes that token’s rotation family — the lineage descending from a single login — not every token the actor owns. The actor’s other devices/sessions, which have their own families, keep working. Access tokens minted within the revoked family are revoked with it.

  • Within the grace window a reuse is treated as a benign concurrent/dropped rotation and still returns a fresh refresh token, so a client that lost a prior rotation recovers instead of being locked out.

  • The grace window is 60 seconds by default and is set per deployment with ActingWebApp.with_refresh_token_grace(seconds), from 0 to 60. The same value applies to MCP refresh tokens. See Choosing the grace period.

async function refreshTokens() {
    const response = await fetch('/oauth/spa/token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            grant_type: 'refresh_token',
            refresh_token: getStoredRefreshToken(),
            token_delivery: 'json'
        })
    });

    const data = await response.json();

    if (data.success) {
        // IMPORTANT: Store BOTH new tokens (rotation)
        setAccessToken(data.access_token);
        setRefreshToken(data.refresh_token);  // New refresh token!
    } else {
        // Refresh failed - user must re-authenticate
        redirectToLogin();
    }
}

Choosing the grace period

app = ActingWebApp(...).with_refresh_token_grace(30)

The grace period is how long after a refresh token is consumed that presenting it again still rotates, instead of counting as theft. The default and the ceiling are 60 seconds (Okta’s maximum for the same setting); a value outside 0 to 60, or one that is not an integer, raises ValueError wherever it is set.

The grace period has a cost. Inside it, whoever presents the consumed token receives a new branch of the rotation family. A thief who replays a copied token inside the grace window and the legitimate client then both hold working branches, and neither is flagged unless one of them later replays one of its own consumed tokens. A shorter grace honours a copied token for less time.

0 means no grace: every reuse of a consumed token inside the two-day reuse window is answered as theft and its chain revoked (a reuse after that, or after the token’s own expiry, is answered as expired, and a token issued before rotation families existed revokes only itself). That includes two tabs or windows refreshing the same token at the same moment, and a client retrying a refresh that consumed its token but failed to store the new one (answered 503); both are signed out. A read or consume fault leaves the token untouched, so its retry still works. When two requests present the same token at once, the one that consumed it may keep the new tokens it is issued: they survive if they are created after the other request revoked the chain, and are revoked with it if created before. So 0 stops a later replay, not reliably a simultaneous one. Use 0 only with a client that single-flights its refreshes.

The grace period does not cover a client that loses a refresh response and retries minutes later (a laptop that went to sleep mid-request): no value up to 60 seconds reaches that far, and a longer one would widen the window a copied token is honoured in. That case is the client’s to prevent; see the next section.

A token’s own expiry comes first: a consumed refresh token presented after its expires_at is refused as expired, even inside the grace period. So a client that refreshes in the last moments of a refresh token’s lifetime and loses the response is not rescued by a retry; it signs in again. Each rotation issues a refresh token with a fresh lifetime, so this only affects a client that has not refreshed for almost the whole lifetime.

Refreshing reliably on native and mobile clients

A refresh consumes the token it presents. If the device never receives the response, the client still holds the consumed token, and presenting it again after the grace period reads as theft: the rotation family is revoked and the user must sign in again. Browsers rarely lose a response; native and mobile apps do, when the device sleeps or the app is suspended with a refresh in flight. These rules make the lockout unlikely. Rules 1 and 2 are a mitigation, not a guarantee: what a platform lets an app observe and hold open varies, and some clients (a WebView, an iPad app running on a Mac) have little of either.

  1. Refresh only while the app is active or the page is visible, and not from a timer. Refresh when the user brings the app forward or before a request that needs a fresh access token. A background timer can fire during a dark wake (Power Nap) or while the app is being suspended, and a dark wake does not make an app active. A native AppKit app can also stop starting refreshes on NSWorkspaceWillSleepNotification (posted on NSWorkspace.shared.notificationCenter); an iOS binary, including one running on a Mac as “Designed for iPad”, has no such signal.

  2. Keep the process that owns the connection running until the response arrives, where the platform allows it: on iOS beginBackgroundTask, on macOS a ProcessInfo.beginActivity or IOKit power assertion. These cover only the process that makes the request: in a WKWebView the fetch runs in WebKit’s networking process, so a background task in the app process may not cover it. An idle-sleep assertion does not stop the user closing the lid or choosing Sleep.

  3. On an ambiguous failure, keep the old token and retry once, at once, while awake. A timeout or a dropped connection does not tell the client whether the server consumed the token. A retry inside the grace period (60 seconds by default) rotates again, so a prompt retry recovers. A retry after a sleep is the reuse the server answers as theft; there, only rules 1 and 2 help.

  4. One refresh in flight per token, across tabs and windows as well (a lock, or a BroadcastChannel), as in the single-flight example under Troubleshooting.

  5. Store the new refresh token before relying on it. If the store fails, keep the new tokens in memory, retry the store, and never fall back to the old refresh token: the server has already consumed it, and presenting it after the grace period is theft. The one case this cannot cover is the process dying before a store succeeds; a cold start then reads the consumed token.

  6. Give the refresh request a timeout short enough that one immediate retry still lands inside the grace period: roughly 15 to 20 seconds against the 60-second default, shorter if the deployment lowers the grace. A timer does not run while the device sleeps, so a request parked across a sleep is already past the grace when the client wakes; the timeout helps only while the device is awake.

  7. Treat a 401 on refresh as final: clear the session and send the user to sign in. Do not retry the same token. A 503 is not final: it means the server could not reach its token store. Keep the token and retry after the Retry-After seconds; the token is either untouched or, if the server consumed it before the fault, rotates again on a retry inside the grace period.

Mobile App Authentication

Native mobile apps (iOS and Android) can authenticate using the authorization_code grant type on POST /oauth/spa/token. Unlike SPAs, mobile apps catch the authorization code via a custom URL scheme deep link rather than a browser callback page.

How It Differs from SPA Flow:

  • Mobile apps open the system browser for OAuth (per RFC 8252)

  • The OAuth provider redirects to a custom URL scheme (e.g., io.actingweb.myapp://callback)

  • The mobile OS routes the deep link back to the app with the authorization code

  • The app sends the code directly to POST /oauth/spa/token – no /oauth/callback round-trip needed

Authorization Code Exchange

After receiving the authorization code via deep link, the mobile app exchanges it for tokens:

Request:

{
    "grant_type": "authorization_code",
    "code": "AUTH_CODE_FROM_PROVIDER",
    "provider": "github-mobile",
    "redirect_uri": "io.actingweb.myapp://callback",
    "token_delivery": "json"
}

Parameters:

  • grant_type: Must be authorization_code

  • code: The authorization code received from the OAuth provider via deep link

  • provider: The provider variant name (e.g., google-mobile, github-mobile)

  • redirect_uri: The custom URL scheme used for the OAuth redirect

  • token_delivery: Token delivery mode (json recommended for mobile)

Response:

{
    "success": true,
    "actor_id": "abc123",
    "email": "user@example.com",
    "access_token": "eyJhbGciOiJIUzI1NiIs...",
    "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
    "token_type": "Bearer",
    "expires_in": 3600,
    "expires_at": 1699876543
}

Error Response:

{
    "success": false,
    "error": "invalid_grant",
    "message": "Authorization code exchange failed"
}

Mobile Token Storage

Unlike SPAs (which store tokens in memory), mobile apps should use platform-secure storage:

  • iOS: Keychain Services

  • Android: Android Keystore / EncryptedSharedPreferences

Token refresh works identically to SPA flow using grant_type=refresh_token on the same POST /oauth/spa/token endpoint.

Session Management

Check Session Status

async function checkSession() {
    const response = await fetch('/oauth/session', {
        headers: {
            'Authorization': `Bearer ${getAccessToken()}`
        }
    });

    const session = await response.json();

    if (session.authenticated) {
        console.log(`Logged in as actor ${session.actor_id}`);
        console.log(`Token expires in ${session.expires_in} seconds`);
    } else {
        // Not authenticated or token expired
        redirectToLogin();
    }
}

Logout

/oauth/logout ends the session’s whole refresh-token chain: the access token, the refresh token and every token rotated from them. Another device of the same user has its own chain and keeps working. (Before 3.15.1 it deleted only the access token, so the refresh token still worked for up to 14 days.)

An access token expires after an hour, and an expired token cannot be looked up, so a client that logs out after being idle should also send its refresh token, in the JSON body or in the refresh_token cookie. With no access token the refresh token alone drives the revocation; with both, each is revoked.

async function logout() {
    const headers = { 'Content-Type': 'application/json' };
    if (getAccessToken()) {
        headers['Authorization'] = `Bearer ${getAccessToken()}`;
    }

    let response;
    try {
        response = await fetch('/oauth/logout', {
            method: 'POST',
            headers,
            // Cookie-mode clients send no body; the refresh_token cookie
            // travels with credentials.
            body: JSON.stringify({ refresh_token: getRefreshToken() }),
            credentials: 'include'
        });
    } catch (err) {
        return false;  // network error: the session may still be live
    }

    if (response.status === 503) {
        // The token store faulted and revoked nothing. The tokens still
        // work, so keep them and retry (RFC 7009 §2.2.1: on 503 the
        // client must assume the token still exists).
        const seconds = Number(response.headers.get('Retry-After')) || 5;
        await new Promise(resolve => setTimeout(resolve, seconds * 1000));
        return logout();
    }

    if (!response.ok) {
        return false;
    }

    // Clear local tokens only after a 2xx.
    clearTokens();
    const result = await response.json();
    window.location.href = result.redirect_url;
    return true;
}

A store fault answers 503 with Retry-After: 5 and {"success": false, "error": "temporarily_unavailable"}. Nothing was revoked, no cookie was cleared, and the presented token is still the handle for the retry. Retry promptly: if the access token expires before the retry, send the refresh token as well, which anchors on a 14-day row. A cross-origin SPA can read Retry-After because the endpoint lists it in Access-Control-Expose-Headers; a proxy that strips that header leaves the client to a fixed delay.

Note

Known limitation: logout racing a refresh. A refresh request that has already consumed the old refresh token when the logout runs can mint its successor tokens after the chain was deleted, so a logout that answers 200 can leave one live token pair behind for that chain. The window is milliseconds and needs a refresh in flight at the moment of logout; a client should not refresh while it is logging out (single-flight the two). A second logout, or a revoke of the new refresh token, ends it. This is tracked as a follow-up.

Note

Logout is a session action, not an account disconnect. /oauth/logout revokes the ActingWeb session token and its chain and, for a POST, clears the stored identity-provider token locally (so the backend can no longer call provider APIs on the user’s behalf; a GET ends the chain but leaves that token alone). The provider token is stored once per actor, so this clears it for every device of that actor, not only this chain. It clears the provider access token only: the provider refresh token in actor.store.oauth_refresh_token is left in place, and nothing in the library reads it, so an application that refreshes provider tokens itself should clear it on logout. It does not call the provider’s token-revocation endpoint. This is deliberate: revoking the upstream grant for Sign in with Apple emails the user and severs the grant, forcing a fresh consent prompt on the next login. Provider-side revocation is reserved for an explicit account-disconnect / delete flow. (Changed in 3.11.0.)

Note

GET /oauth/logout with the session cookies ends the chain too. A cross-site top-level navigation carries SameSite=Lax cookies, so another site can force one device’s logout. That exposes nothing and ends one session. A GET leaves the actor’s stored provider token alone: only a POST (or /oauth/revoke) clears it, since it is shared by every device; use POST where that matters.

Note

If the handler itself fails (a bug, not a store fault) the integrations answer a plain 500 without CORS headers, and a cross-origin browser sees a network error. This is the same as every other SPA endpoint.

Token Revocation

Explicitly revoke a token (for example when the user signs out from another device). The token may be an access token or a refresh token, and revoking either ends the whole chain it belongs to, a used refresh token included, so the theft check keeps its effect. A token issued before 3.15.1 that has no chain revokes only itself.

async function revokeToken(token, tokenType = 'access_token') {
    const response = await fetch('/oauth/revoke', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            token: token,
            token_type_hint: tokenType
        })
    });

    if (response.status === 503) {
        // Nothing was revoked; the token still works. Retry after
        // Retry-After seconds.
        throw new Error('retry');
    }
}

The hint is optional: if it does not locate the token the other token type is searched (RFC 7009 §2.1), and an unrecognised hint is ignored. An unknown token answers 200 (RFC 7009 §2.2). The token may also come from the Authorization header, the refresh_token cookie (so a cookie-mode browser can revoke after its access token expired) or the access_token cookie.

Complete Example

Here’s a complete SPA authentication flow:

// auth.js - SPA Authentication Module

class AuthManager {
    constructor() {
        this.accessToken = null;
        this.refreshToken = null;
        this.expiresAt = null;
    }

    async getConfig() {
        const response = await fetch('/oauth/config');
        return response.json();
    }

    async login(provider = 'google') {
        // Store return URL
        sessionStorage.setItem('auth_return_url', window.location.pathname);

        // Initiate OAuth with server-managed PKCE
        const response = await fetch('/oauth/spa/authorize', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                provider: provider,
                redirect_uri: window.location.origin + '/callback',
                pkce: 'server',
                token_delivery: 'hybrid'  // Best balance of security
            })
        });

        const auth = await response.json();

        // Redirect to OAuth provider
        window.location.href = auth.authorization_url;
    }

    async handleCallback() {
        const params = new URLSearchParams(window.location.search);

        if (params.get('error')) {
            // 'identifier_failed' covers both "no identifier could be
            // extracted" and a provider verified-emails API failure (e.g.
            // GitHub's /user/emails call itself failed); its
            // error_description is retry-worded ("please try signing in
            // again") rather than blaming the user's provider settings.
            throw new Error(params.get('error_description') || 'OAuth failed');
        }

        // OAuth provider redirects here; endpoint auto-detects SPA mode via state
        const response = await fetch('/oauth/callback?' + params.toString());
        const tokens = await response.json();

        if (!tokens.success) {
            throw new Error(tokens.message || 'Token exchange failed');
        }

        // Store access token in memory (hybrid mode)
        this.accessToken = tokens.access_token;
        this.expiresAt = tokens.expires_at;

        // Refresh token is in HttpOnly cookie (hybrid mode)

        // Return to original URL
        const returnUrl = sessionStorage.getItem('auth_return_url') || '/';
        sessionStorage.removeItem('auth_return_url');

        return { success: true, returnUrl };
    }

    async refreshTokens() {
        // With hybrid mode, refresh token is in cookie
        const response = await fetch('/oauth/spa/token', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            credentials: 'include',  // Include cookies
            body: JSON.stringify({
                grant_type: 'refresh_token',
                token_delivery: 'hybrid'
            })
        });

        const data = await response.json();

        if (data.success) {
            this.accessToken = data.access_token;
            this.expiresAt = data.expires_at;
            return true;
        }

        return false;
    }

    async authenticatedFetch(url, options = {}) {
        // Check if token needs refresh (5 min buffer)
        if (this.expiresAt && Date.now() / 1000 > this.expiresAt - 300) {
            const refreshed = await this.refreshTokens();
            if (!refreshed) {
                throw new Error('Session expired');
            }
        }

        return fetch(url, {
            ...options,
            headers: {
                ...options.headers,
                'Authorization': `Bearer ${this.accessToken}`
            }
        });
    }

    async logout() {
        const response = await fetch('/oauth/logout', {
            method: 'POST',
            headers: {
                'Authorization': `Bearer ${this.accessToken}`
            },
            credentials: 'include'
        });

        // A 503 means the store revoked nothing and the session is still
        // live: keep the tokens and let the user retry. Clear local tokens
        // only after a 2xx.
        if (!response.ok) {
            throw new Error('Logout failed; try again');
        }

        this.accessToken = null;
        this.expiresAt = null;

        window.location.href = '/';
    }

    isAuthenticated() {
        return !!this.accessToken && (!this.expiresAt || Date.now() / 1000 < this.expiresAt);
    }
}

// Usage
const auth = new AuthManager();

// On login button click
document.getElementById('login-btn').onclick = () => auth.login('google');

// On callback page
if (window.location.pathname === '/callback') {
    auth.handleCallback()
        .then(result => window.location.href = result.returnUrl)
        .catch(err => alert('Login failed: ' + err.message));
}

Security Best Practices

Token Storage

Recommended: Store access tokens in memory (JavaScript closure or class property)

// GOOD: Token in memory
class TokenManager {
    #accessToken = null;

    setToken(token) {
        this.#accessToken = token;
    }

    getToken() {
        return this.#accessToken;
    }
}

// AVOID: Token in localStorage (vulnerable to XSS)
// localStorage.setItem('access_token', token);  // Don't do this!

CORS Configuration

For production, configure specific allowed origins:

app = (
    ActingWebApp(...)
    .with_spa_cors_origins(
        'https://myapp.example.com',
        'https://staging.myapp.example.com',
    )
)

The default is "*" (echo the request origin — allow all), which is fine for development; restrict it in production. Calling with no arguments resets to allow-all.

Redirect Origin Allowlist

The redirect_uri passed to POST /oauth/spa/authorize is validated against an allowlist (the backend FQDN plus the origins of configured OAuth redirect URIs and Apple mobile deep links). An off-origin redirect_uri is rejected with 400, closing an open-redirect / one-time-session-id leak. Same-origin SPAs need no configuration. If your SPA is served from a different origin than the backend, allow it explicitly:

app = (
    ActingWebApp(...)
    .with_spa_redirect_origins('https://app.example.com')
)

Each origin is scheme + host (+ optional port). Calling the builder with no arguments clears the list. (Equivalent to setting Config.spa_redirect_origins.)

HTTPS Required

Always use HTTPS in production. Cookie-based tokens require Secure flag:

app = ActingWebApp(
    ...
    proto='https://'  # Required for secure cookies
)

Content Security Policy

Add CSP headers to prevent XSS:

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'

Alternative: Factory JSON API

For simpler integrations, the factory endpoint also supports JSON:

// Get OAuth config from factory
const config = await fetch('/?format=json', {
    headers: { 'Accept': 'application/json' }
}).then(r => r.json());

// config includes all OAuth endpoints and providers

API Reference

GET /oauth/config

Returns OAuth configuration.

Response:

{
    "oauth_enabled": true,
    "oauth_providers": [
        {
            "name": "google",
            "display_name": "Google",
            "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth"
        },
        {
            "name": "github",
            "display_name": "GitHub",
            "authorization_endpoint": "https://github.com/login/oauth/authorize"
        }
    ],
    "pkce_supported": true,
    "pkce_methods": ["S256"],
    "spa_mode_supported": true,
    "token_delivery_modes": ["json", "cookie", "hybrid"],
    "refresh_token_rotation": true,
    "endpoints": {...}
}

The oauth_providers array contains one entry per configured provider. When only one provider is configured, the array has a single entry.

Note

Trust types are NOT included in this response. Trust types are only relevant for MCP client authorization (ActingWeb as OAuth server), not for user login (ActingWeb as OAuth client). For MCP authorization, use /oauth/authorize.

POST /oauth/spa/authorize

Initiate OAuth flow for SPA.

Request Body:

{
    "provider": "google",
    "trust_type": "mcp_client",
    "redirect_uri": "https://myapp.example.com/callback",
    "return_path": "/app",
    "pkce": "server",
    "token_delivery": "json"
}

Parameters:

  • provider: OAuth provider name (google, github)

  • trust_type: (Optional) Trust type for MCP authorization. Omit for simple user login.

  • redirect_uri: Where OAuth provider should redirect (your SPA callback page)

  • return_path: (Optional) Final redirect path after auth. Default: /app. Prepended with actor ID: /{actor_id}{return_path}. Supports {actor_id} placeholder: /{actor_id}/dashboard.

  • pkce: server (recommended) or client

  • token_delivery: json, cookie, or hybrid

Response:

{
    "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
    "state": "...",
    "code_challenge": "...",
    "code_challenge_method": "S256",
    "pkce_managed_by": "server"
}

GET /oauth/callback

Handle OAuth callback. Auto-detects SPA mode via spa_mode: true in state parameter.

Behavior:

  • Browser navigation (no Accept: application/json header): Redirects to the SPA’s redirect_uri with code and state preserved.

  • Fetch with JSON (Accept: application/json header): Returns JSON response with tokens.

Query Parameters:

  • code: Authorization code from OAuth provider

  • state: State parameter for CSRF protection

Response (JSON mode):

{
    "success": true,
    "actor_id": "abc123",
    "email": "user@example.com",
    "access_token": "...",
    "refresh_token": "...",
    "token_type": "Bearer",
    "expires_in": 3600,
    "expires_at": 1699876543,
    "redirect_url": "/abc123/app"
}

The redirect_url is constructed from the return_path parameter passed during authorization (default: /app), prepended with the actor ID.

POST /oauth/spa/token

Token exchange and refresh with rotation.

Request Body (Refresh):

{
    "grant_type": "refresh_token",
    "refresh_token": "...",
    "token_delivery": "json"
}

Response:

{
    "success": true,
    "access_token": "new_access_token",
    "refresh_token": "new_refresh_token",
    "token_type": "Bearer",
    "expires_in": 3600,
    "refresh_token_expires_in": 1209600
}

POST /oauth/revoke

Revoke a token and the refresh-token chain it belongs to.

Request Body:

{
    "token": "token_to_revoke",
    "token_type_hint": "access_token"
}

token_type_hint is optional ("access_token" or "refresh_token"). Without token the endpoint reads the Authorization header, then the refresh_token cookie, then the access_token cookie.

Response:

{
    "success": true,
    "message": "Token revoked successfully"
}

An unknown token also answers 200. A token store fault answers 503 with Retry-After: 5, revokes nothing and clears no cookie:

{
    "error": true,
    "status_code": 503,
    "message": "Token store temporarily unavailable; retry"
}

The stored provider token is cleared for the actor after a confirmed revocation, for every device of that actor.

GET /oauth/session

Check session status.

Headers:

  • Authorization: Bearer <access_token>

Response (Authenticated):

{
    "authenticated": true,
    "actor_id": "abc123",
    "identifier": "user@example.com",
    "expires_at": 1699876543,
    "expires_in": 3245
}

Response (Not Authenticated):

{
    "authenticated": false,
    "message": "No active session"
}

POST /oauth/logout

Revoke the session’s refresh-token chain and clear its cookies.

Headers:

  • Authorization: Bearer <access_token> (optional)

Request Body (optional JSON):

{
    "refresh_token": "the_refresh_token"
}

The refresh token may also arrive in the refresh_token cookie (any method). When both the body and the cookie name a token, and when an access token and a refresh token are both present, each is revoked. A missing, empty or malformed body is ignored, never a 400.

Response:

{
    "success": true,
    "message": "Successfully logged out",
    "redirect_url": "https://example.com/",
    "cleared_cookies": ["access_token", "oauth_token", "refresh_token", "session_id"]
}

The refresh_token cookie is cleared at / and at /oauth/spa/token. A token store fault answers 503 with Retry-After: 5 and no Set-Cookie:

{
    "success": false,
    "error": "temporarily_unavailable",
    "message": "Token store temporarily unavailable; retry the logout",
    "method": "POST"
}

The stored provider token is cleared for the actor after a confirmed revocation of a POST, for every device of that actor.

GET /oauth/email

Email collection form (when OAuth provider doesn’t provide email) or email verification.

Email form (session parameter):

GET /oauth/email?session=<session_id>
Accept: application/json

Response:

{
    "action": "email_required",
    "session_id": "...",
    "form_action": "/oauth/email",
    "form_method": "POST",
    "provider": "github",
    "provider_display": "GitHub",
    "message": "Your GitHub account does not have a public email...",
    "verified_emails": [],
    "has_verified_emails": false
}

Email verification (verify parameter):

GET /oauth/email?verify=<token>

Validates the token and marks the email as verified. Returns HTML success page or JSON response based on Accept header.

POST /oauth/email

Submit email address to complete actor creation.

Request Body:

{
    "session": "<session_id>",
    "email": "user@example.com"
}

Response:

{
    "success": true,
    "status": "success",
    "actor_id": "abc123",
    "email": "user@example.com",
    "redirect_url": "/abc123/www",
    "email_requires_verification": true,
    "access_token": "...",
    "token_type": "Bearer"
}

When email_requires_verification is true, the email_verification_required lifecycle hook has been fired. Your backend hook handler should send the verification email.

Error responses:

A free-text address that already has an actor is refused rather than silently adopted — the pending session is consumed, so a resubmit needs a fresh /oauth/callback round trip:

{
    "error": true,
    "code": "actor_exists",
    "status_code": 409,
    "message": "An account already exists for this email address. Sign in again to use a different address."
}

If your application’s oauth_success lifecycle hook rejects the login (returns a falsy value), the actor row is left in place but no session cookie or access token is issued:

{
    "error": true,
    "code": "authentication_rejected",
    "status_code": 403,
    "message": "Authentication rejected"
}

Every other error from this endpoint (and from GET /oauth/email) carries the same shape without a code field, for any Accept: application/json request:

{
    "error": true,
    "status_code": 400,
    "message": "..."
}

Troubleshooting

“PKCE verification failed”

If using client-managed PKCE, ensure the code verifier is stored and sent correctly:

// Store verifier BEFORE redirect
sessionStorage.setItem('pkce_verifier', verifier);

// Retrieve AFTER callback
const verifier = sessionStorage.getItem('pkce_verifier');

“Refresh token already used”

This error indicates refresh token reuse detection. There are two common causes:

  1. Concurrent refresh requests - Multiple requests attempting to use the same refresh token

  2. Token theft - A legitimate security concern that triggers token revocation

Server-Side Protection

ActingWeb handles concurrent refresh requests gracefully using atomic compare-and-swap operations. When multiple requests attempt to use the same refresh token simultaneously:

  • Only the first request succeeds in marking the token as used

  • Subsequent requests within the grace window (60 seconds by default; see Choosing the grace period) receive a full rotation — new access and refresh tokens — without error. This also covers a client that dropped a prior rotation (e.g. a mobile WebView suspended before it persisted the rotated token): it recovers instead of being locked out.

  • A reuse outside the grace window is treated as theft and revokes the offending token’s rotation family (the lineage from one login), returning 401. Other devices/sessions, which have their own families, are unaffected.

This means the server automatically handles most race conditions without client-side coordination.

Client-Side Best Practice (recommended)

Serializing refresh calls (single-flight) is strongly recommended so a client never issues two concurrent refreshes that fork its own rotation lineage, and so a 401 degrades to a login screen rather than a blank page:

// Serialize refresh requests (recommended but not required)
let refreshPromise = null;

async function safeRefresh() {
    if (refreshPromise) return refreshPromise;

    refreshPromise = refreshTokens();
    try {
        return await refreshPromise;
    } finally {
        refreshPromise = null;
    }
}

When Tokens Are Revoked

If you see this error AND the session is revoked (401 on subsequent requests), it indicates:

  • A reused refresh token was presented well after it was first used (beyond the grace window) — genuine theft, a client that forked its own rotation lineage by issuing concurrent/uncoordinated refreshes (see the single-flight best practice above), or a client that lost a refresh response to sleep or suspension, or failed to store the new token (see Refreshing reliably on native and mobile clients)

  • Only the affected rotation family is revoked; the user re-authenticates that session. Other devices/sessions continue working.

  • Treat the 401 as “session expired”: route to the login screen. Do not leave the app on a blank page.

“CORS error”

Ensure your SPA origin is allowed and credentials are included:

fetch('/oauth/spa/token', {
    method: 'POST',
    credentials: 'include',  // Required for cookies
    headers: {
        'Content-Type': 'application/json'
    }
});

Migration from Standard OAuth

If migrating from standard OAuth to SPA endpoints:

  1. Replace redirect-based callbacks with JSON responses

  2. Add PKCE to authorization requests

  3. Implement token refresh with rotation

  4. Update token storage from cookies/localStorage to memory

// Before: Standard OAuth with redirect (returns HTML)
window.location.href = '/oauth/callback?code=...';

// After: SPA OAuth with JSON (same endpoint, auto-detects via state param)
const tokens = await fetch('/oauth/callback?code=...&state={"spa_mode":true,...}')
    .then(r => r.json());