Hosted login for an SPA
The recommended integration: your app never sees a password, tokens never ride the URL, and the sign-in screens are AppID’s problem (branded as yours).
Prereqs: a pool with the local connection, and your callback in settings.redirect_uris (Quickstart steps 1–2).
1. Redirect to /authorize with PKCE
const ISSUER = 'https://appid.dodil.io/acme/my-app'
const CLIENT_ID = 'my-web' // your app's name for itself
const REDIRECT = 'https://app.example.com/callback'
function b64url(bytes) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function login() {
const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)))
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
const challenge = b64url(new Uint8Array(digest))
const state = b64url(crypto.getRandomValues(new Uint8Array(16)))
sessionStorage.setItem('pkce_verifier', verifier)
sessionStorage.setItem('oauth_state', state)
location.href = `${ISSUER}/authorize?` + new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT,
state,
code_challenge: challenge,
code_challenge_method: 'S256',
scope: 'openid',
})
}There is no client secret — AppID clients are public and PKCE S256 is mandatory. The user signs in (or signs up, or recovers their password) on the hosted page and comes back to your redirect_uri with ?code=…&state=…&iss=….
2. Exchange the code at your callback
const params = new URLSearchParams(location.search)
if (params.get('state') !== sessionStorage.getItem('oauth_state')) throw new Error('state mismatch')
if (params.get('error') === 'access_denied'
&& params.get('error_description') === 'login_session_expired') {
login() // the 30-min login window lapsed — just start over
}
const res = await fetch(`${ISSUER}/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code: params.get('code'),
redirect_uri: REDIRECT,
code_verifier: sessionStorage.getItem('pkce_verifier'),
client_id: CLIENT_ID,
}),
})
const { access_token, refresh_token, id_token, expires_in } = await res.json()Codes are single-use and expire in 60 seconds — exchange immediately. Because we asked for scope=openid, an id_token arrives too (aud = your client_id).
If your SPA calls {issuer} endpoints directly from the browser (this exchange included), the app’s origin must be in the pool’s policies.cors_allowed_origins.
3. Keep the session fresh
async function refresh(rt) {
const r = await fetch(`${ISSUER}/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: rt }),
})
if (!r.ok) return login() // family revoked or expired — sign in again
return r.json() // ALWAYS replace the stored refresh_token
}Refresh tokens rotate every use — persist the newest one only. Reuse of an old token revokes the whole session (a ~10 s grace window forgives genuine double-submits). Sign out with POST {issuer}/logout { refresh_token }.
4. Smoke-test without a browser
dodil appid pool test-flow my-app [email protected] 'S3curePass!' \
--redirect-uri https://app.example.com/callbackSame flow, headless — run it whenever you change settings.
Branding
The hosted pages (and the verification/reset emails) render from the pool’s branding document:
dodil appid branding set my-app @branding.json{
"logo_url": "https://cdn.example.com/logo.svg",
"background_image_url": "https://cdn.example.com/bg.jpg",
"colors": { "primary": "#4f46e5", "background": "#ffffff", "surface": "#f8f8fc",
"text": "#111118", "muted": "#667", "error": "#c0392b" },
"dark": { "colors": { "background": "#0b0b12", "surface": "#15151f", "text": "#eee" } },
"radius": "12px",
"font_family": "Inter",
"font_url": "https://fonts.googleapis.com/css2?family=Inter&display=swap",
"layout": "card",
"copy": { "title": "Sign in to My App", "signup_title": "Create your account",
"subtitle": "Welcome back", "footer": "© Acme" },
"links": { "terms": "https://example.com/terms", "privacy": "https://example.com/privacy",
"support": "https://example.com/help" },
"custom_css": ".appid-card { box-shadow: none }",
"email": { "from_name": "My App", "logo_url": "https://cdn.example.com/logo.png",
"accent": "#4f46e5", "footer": "Acme Inc, 1 Example Street" }
}Every key is optional. layout is card, split, or minimal; image URLs must be https; colors are validated (#hex / rgb() / hsl()); custom_css is size-capped and applied last. Contrast for text on your primary color is computed automatically. Changes go live immediately — the page fetches branding from GET {issuer}/branding on load.
Bringing your own login UI instead? Skip
/authorizeentirely and use the direct grants from your own screens —signup,token(password/refresh),recover. The branding endpoint hands your UI the pool’spassword_min_lengthso client-side validation matches the server.