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 |
|
|
|---|---|---|
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:
``/login`` - SPA login page with OAuth buttons
``/<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 → |
|
After OAuth login completes |
|
Authenticated browser → |
|
This eliminates the need for custom route handlers to redirect browsers. Your SPA only needs to:
Serve the login page at
/loginServe the SPA shell at
/<actor_id>/appHandle 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:
Performance: No network calls to validate tokens on every API request
Reliability: No dependency on OAuth provider availability after initial auth
Security: OAuth provider tokens never exposed to frontend JavaScript
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 |
|---|---|---|
|
GET |
Get OAuth configuration and available providers |
|
POST |
Initiate external OAuth flow (ActingWeb as OAuth client) |
|
GET |
Handle OAuth callback (auto-detects SPA mode via state param) |
|
POST |
Token refresh with rotation for external provider tokens |
|
POST |
Revoke access and/or refresh tokens |
|
GET |
Check current session status |
|
POST/GET |
Logout and clear all tokens (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 response = await fetch('/oauth/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
session: sessionId,
email: userEmail
})
}).then(r => r.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;
}
}
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"
// }
PKCE Support
PKCE (Proof Key for Code Exchange) is essential for SPAs because they cannot securely store client secrets. ActingWeb supports two PKCE modes:
Server-Managed PKCE (Recommended)
The server generates and manages the PKCE challenge/verifier pair:
const auth = await fetch('/oauth/spa/authorize', {
method: 'POST',
body: JSON.stringify({
provider: 'google',
pkce: 'server' // Server generates PKCE
})
});
// Response includes PKCE info
// {
// "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
// "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
// "code_challenge_method": "S256",
// "pkce_managed_by": "server"
// }
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/tokencalls 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.
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();
}
}
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/callbackround-trip needed
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
async function logout() {
const response = await fetch('/oauth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${getAccessToken()}`
}
});
const result = await response.json();
// Clear local token storage
clearTokens();
// Redirect to home
window.location.href = result.redirect_url;
}
Token Revocation
Explicitly revoke tokens (e.g., when user logs out from another device):
async function revokeToken(token, tokenType = 'access_token') {
await fetch('/oauth/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: token,
token_type_hint: tokenType
})
});
}
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')) {
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() {
await fetch('/oauth/spa/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.accessToken}`
},
credentials: 'include'
});
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.
GET /oauth/callback
Handle OAuth callback. Auto-detects SPA mode via spa_mode: true in state parameter.
Behavior:
Browser navigation (no
Accept: application/jsonheader): Redirects to the SPA’sredirect_uriwith code and state preserved.Fetch with JSON (
Accept: application/jsonheader): Returns JSON response with tokens.
Query Parameters:
code: Authorization code from OAuth providerstate: 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 tokens.
Request Body:
{
"token": "token_to_revoke",
"token_type_hint": "access_token"
}
Response:
{
"success": true,
"message": "Token revoked successfully"
}
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
Logout and clear session.
Headers:
Authorization: Bearer <access_token>(optional)
Response:
{
"success": true,
"message": "Logged out successfully",
"redirect_url": "/"
}
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.
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:
Concurrent refresh requests - Multiple requests attempting to use the same refresh token
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 (up to ~60 seconds) 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, or a client that forked its own rotation lineage by issuing concurrent/uncoordinated refreshes (see the single-flight best practice above)
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:
Replace redirect-based callbacks with JSON responses
Add PKCE to authorization requests
Implement token refresh with rotation
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());