Something went wrong. Try again.
AtAuth
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877/** * Forward-Auth Proxy Routes * * Implements nginx auth_request compatible endpoints for protecting * arbitrary services behind ATAuth SSO. * * Flow: * 1. User hits protected service -> nginx auth_request -> GET /auth/verify -> 401 * 2. nginx redirects to GET /auth/proxy/login?rd=<original_url> * 3. If ATAuth session exists: silent SSO redirect with ticket * If not: render login page, user does AT Proto OAuth * 4. After auth: redirect back to original URL with _atauth_ticket param * 5. nginx auth_request fires again -> GET /auth/verify validates ticket, * returns 200 + Set-Cookie for the protected domain */
import { Router, Request, Response } from 'express';import crypto from 'crypto';import type { DatabaseService } from '../services/database.js';import type { OAuthService } from '../services/oauth.js';import type { PasskeyService } from '../services/passkey.js';import type { ForwardAuthConfig, AccessCheckResult } from '../types/proxy.js';import { checkAccess } from '../utils/access-check.js';import { SESSION_COOKIE_NAME, PROXY_COOKIE_NAME, createSessionCookie, verifySessionCookie, createProxyCookie, verifyProxyCookie, createAuthTicket, verifyAuthTicket, parseCookies, isAllowedRedirect, extractOrigin,} from '../utils/proxy-auth.js';
export function createProxyAuthRoutes( db: DatabaseService, oauthService: OAuthService, forwardAuthConfig: ForwardAuthConfig, oidcIssuer: string, passkeyService: PasskeyService | null = null,): Router { const router = Router(); const secret = forwardAuthConfig.sessionSecret;
/** * Check access rules for a user attempting to access a protected origin. * Returns allowed if no rules are configured (backward compat). */ function enforceAccess(did: string, handle: string, redirectUri: string): AccessCheckResult { const targetOrigin = extractOrigin(redirectUri); if (!targetOrigin) { return { allowed: false, matched_rule_id: null, reason: 'Invalid redirect URI' }; }
const originId = db.getOriginIdByOrigin(targetOrigin); if (originId === null) { return { allowed: false, matched_rule_id: null, reason: 'Origin not registered' }; }
const rules = db.getProxyAccessRulesForCheck(originId); const totalRules = rules.denyRules.length + rules.originAllowRules.length + rules.globalAllowRules.length; if (totalRules === 0) { return { allowed: true, matched_rule_id: null, reason: 'No access rules configured (open access)' }; }
const result = checkAccess(did, handle, rules); if (!result.allowed) { console.log(`[Proxy ACL] Access denied for ${handle} (${did}) to ${targetOrigin}: ${result.reason}`); } return result; }
// ===== GET /auth/verify ===== // Called by nginx auth_request on every request to a protected service. router.get('/verify', async (req: Request, res: Response) => { const cookieHeader = req.headers.cookie as string | undefined; const originalUrl = req.headers['x-original-url'] as string | undefined; const cookies = parseCookies(cookieHeader);
// 1. Check for per-domain _atauth_proxy cookie const proxyCookie = cookies[PROXY_COOKIE_NAME]; if (proxyCookie) { const sessionId = verifyProxyCookie(proxyCookie, secret); if (sessionId) { const session = db.getProxySession(sessionId); if (session && session.expires_at > Math.floor(Date.now() / 1000)) { db.updateProxySessionActivity(sessionId); res.setHeader('X-Auth-User', session.handle); res.setHeader('X-Auth-DID', session.did); res.setHeader('X-Auth-Handle', session.handle); return res.sendStatus(200); } } }
// 2. Check for _atauth_ticket in the original URL (redirect-back flow) if (originalUrl) { try { // X-Original-URL may be a full URL or just a path+query const url = originalUrl.startsWith('http') ? new URL(originalUrl) : new URL(originalUrl, 'http://placeholder'); const ticket = url.searchParams.get('_atauth_ticket'); if (ticket) { // Determine origin from X-Forwarded headers or the URL itself const forwardedProto = req.headers['x-forwarded-proto'] as string || 'https'; const forwardedHost = req.headers['x-forwarded-host'] as string || url.host; const requestOrigin = `${forwardedProto}://${forwardedHost}`;
const verified = verifyAuthTicket(ticket, secret, requestOrigin); if (verified) { const session = db.getProxySession(verified.sid); if (session && session.expires_at > Math.floor(Date.now() / 1000)) { // Set per-domain cookie via Set-Cookie header // nginx configuration-snippet passes this to the browser const cookieValue = createProxyCookie( verified.sid, secret, forwardAuthConfig.proxyCookieTtl, ); res.setHeader('Set-Cookie', `${PROXY_COOKIE_NAME}=${cookieValue}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${forwardAuthConfig.proxyCookieTtl}`, ); res.setHeader('X-Auth-User', session.handle); res.setHeader('X-Auth-DID', session.did); res.setHeader('X-Auth-Handle', session.handle); return res.sendStatus(200); } } } } catch { // Ignore URL parse errors } }
// 3. Not authenticated return res.sendStatus(401); });
// ===== GET /auth/proxy/login ===== // Browser lands here after nginx redirects on 401. router.get('/proxy/login', async (req: Request, res: Response) => { const rd = req.query.rd as string;
if (!rd) { return res.status(400).send('Missing redirect parameter'); }
// Validate redirect URL against allowed origins const allowedOrigins = db.listProxyAllowedOrigins().map(o => o.origin); if (!isAllowedRedirect(rd, allowedOrigins)) { return res.status(403).type('html').send(renderForbiddenPage(res.locals.cspNonce)); }
// Check for existing ATAuth session cookie const cookies = parseCookies(req.headers.cookie); const sessionCookie = cookies[SESSION_COOKIE_NAME];
if (sessionCookie) { const sessionId = verifySessionCookie(sessionCookie, secret); if (sessionId) { const session = db.getProxySession(sessionId); if (session && session.expires_at > Math.floor(Date.now() / 1000)) { // Check access rules before issuing silent SSO ticket const accessResult = enforceAccess(session.did, session.handle, rd); if (!accessResult.allowed) { return res.status(403).type('html').send(renderAccessDeniedPage(res.locals.cspNonce)); }
// Silent SSO -- generate ticket and redirect back const targetOrigin = extractOrigin(rd); if (targetOrigin) { const ticket = createAuthTicket(session.id, session.did, session.handle, targetOrigin, secret); const redirectUrl = new URL(rd); redirectUrl.searchParams.set('_atauth_ticket', ticket); return res.redirect(redirectUrl.toString()); } } } }
// No valid session -- show login page const now = Math.floor(Date.now() / 1000); const authRequestId = crypto.randomBytes(32).toString('base64url'); db.saveProxyAuthRequest({ id: authRequestId, redirect_uri: rd, created_at: now, expires_at: now + 600, // 10 minutes });
res.type('html').send(renderProxyLoginPage(authRequestId, res.locals.cspNonce, undefined, !!passkeyService)); });
// ===== POST /auth/proxy/login ===== // Handle the login form submission with user's handle. router.post('/proxy/login', async (req: Request, res: Response) => { const { auth_request_id, handle } = req.body;
if (!auth_request_id || !handle) { return res.status(400).send('Missing required parameters'); }
// Sanitize handle (same logic as authorize.ts) let sanitizedHandle = handle.trim().replace(/^@/, ''); sanitizedHandle = sanitizedHandle.replace(/:/g, '.'); sanitizedHandle = sanitizedHandle.replace(/@/, '.'); if (!sanitizedHandle.includes('.')) { sanitizedHandle = sanitizedHandle + '.bsky.social'; }
// Validate handle format: domain-like segments separated by dots if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/.test(sanitizedHandle)) { return res.status(400).json({ error: 'invalid_handle', message: 'Invalid handle format' }); }
// Look up the pending auth request const authRequest = db.getProxyAuthRequest(auth_request_id); if (!authRequest) { return res.status(400).send('Login request expired or invalid'); }
if (authRequest.expires_at < Math.floor(Date.now() / 1000)) { db.deleteProxyAuthRequest(auth_request_id); return res.status(400).send('Login request expired'); }
try { // Start AT Protocol OAuth let atprotoAuth; try { atprotoAuth = await oauthService.generateAuthUrl( 'proxy-auth', // internal client ID for state tracking sanitizedHandle, `${oidcIssuer}/auth/proxy/callback`, ); } catch (firstErr) { const msg = firstErr instanceof Error ? firstErr.message : ''; if (msg.includes('invalid_client_metadata')) { console.log('[Proxy Auth] Retrying after transient client_metadata error...'); await new Promise(r => setTimeout(r, 1000)); atprotoAuth = await oauthService.generateAuthUrl( 'proxy-auth', sanitizedHandle, `${oidcIssuer}/auth/proxy/callback`, ); } else { throw firstErr; } }
if (!atprotoAuth) { return res.status(500).send('Failed to start authentication'); }
// Store mapping: AT Proto state -> proxy auth request ID db.saveOAuthState({ state: atprotoAuth.state, code_verifier: auth_request_id, // Store auth request ID for the callback app_id: 'proxy-auth', redirect_uri: authRequest.redirect_uri, created_at: Math.floor(Date.now() / 1000), });
// Redirect to AT Protocol OAuth const isJsonRequest = req.is('json'); if (isJsonRequest) { res.json({ redirect_url: atprotoAuth.url }); } else { res.redirect(atprotoAuth.url); } } catch (error) { console.error('[Proxy Auth] Login error:', error); let userMessage = 'Authentication failed. Please try again.'; const errMsg = error instanceof Error ? error.message : ''; if (errMsg.includes('resolve identity') || errMsg.includes('Invalid handle')) { userMessage = 'Could not find that handle. Enter your full handle (e.g. yourname.bsky.social or your.custom.domain).'; } else if (errMsg.includes('invalid_client_metadata')) { userMessage = 'Bluesky login is temporarily unavailable. Please wait a moment and try again.'; } const isJsonRequest = req.is('json'); if (isJsonRequest) { res.status(400).json({ error: userMessage }); } else { res.status(400).type('html').send(renderProxyLoginPage(auth_request_id, res.locals.cspNonce, userMessage, !!passkeyService)); } } });
// ===== POST /auth/proxy/passkey ===== // Authenticate via passkey for the forward-auth flow. router.post('/proxy/passkey', async (req: Request, res: Response) => { try { if (!passkeyService) { return res.status(404).json({ error: 'not_found', error_description: 'Passkey authentication is not enabled', }); }
const { auth_request_id, credential, challenge } = req.body;
if (!auth_request_id || !credential || !challenge) { return res.status(400).json({ error: 'invalid_request', error_description: 'Missing required parameters: auth_request_id, credential, challenge', }); }
// Validate the pending auth request const authRequest = db.getProxyAuthRequest(auth_request_id); if (!authRequest) { return res.status(400).json({ error: 'invalid_request', error_description: 'Login request expired or invalid', }); }
if (authRequest.expires_at < Math.floor(Date.now() / 1000)) { db.deleteProxyAuthRequest(auth_request_id); return res.status(400).json({ error: 'invalid_request', error_description: 'Login request expired', }); }
// Verify the passkey const result = await passkeyService.verifyAuthentication(credential, challenge);
if (!result.success || !result.did || !result.handle) { return res.status(401).json({ error: 'authentication_failed', error_description: result.error || 'Passkey authentication failed', }); }
console.log(`[Proxy Passkey] Authenticated user: ${result.handle} (${result.did})`);
// Check access rules before creating session const accessResult = enforceAccess(result.did, result.handle, authRequest.redirect_uri); if (!accessResult.allowed) { db.deleteProxyAuthRequest(auth_request_id); return res.status(403).json({ error: 'access_denied', error_description: 'You do not have access to this service', }); }
// Create a proxy session const now = Math.floor(Date.now() / 1000); const sessionId = crypto.randomBytes(32).toString('base64url'); db.createProxySession({ id: sessionId, did: result.did, handle: result.handle, created_at: now, expires_at: now + forwardAuthConfig.sessionTtl, last_activity: now, user_agent: req.headers['user-agent'], ip_address: req.headers['x-forwarded-for'] as string || req.ip, });
// Set the ATAuth session cookie const sessionCookieValue = createSessionCookie(sessionId, secret, forwardAuthConfig.sessionTtl); res.setHeader('Set-Cookie', `${SESSION_COOKIE_NAME}=${sessionCookieValue}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${forwardAuthConfig.sessionTtl}`, );
// Generate auth ticket for the redirect target const targetOrigin = extractOrigin(authRequest.redirect_uri); if (!targetOrigin) { return res.status(400).json({ error: 'invalid_request', error_description: 'Invalid redirect URI', }); }
const ticket = createAuthTicket(sessionId, result.did, result.handle, targetOrigin, secret);
// Clean up db.deleteProxyAuthRequest(auth_request_id);
// Redirect back to the original URL with ticket const redirectUrl = new URL(authRequest.redirect_uri); redirectUrl.searchParams.set('_atauth_ticket', ticket); res.json({ redirect_url: redirectUrl.toString() }); } catch (error) { console.error('[Proxy Passkey] Error:', error); res.status(500).json({ error: 'server_error', error_description: 'Internal server error', }); } });
// ===== GET /auth/proxy/callback ===== // AT Protocol OAuth callback for the forward-auth flow. router.get('/proxy/callback', async (req: Request, res: Response) => { try { const { code, state, error, error_description, iss } = req.query as { code?: string; state?: string; error?: string; error_description?: string; iss?: string; };
if (error) { console.error(`[Proxy Callback] AT Protocol error: ${error} - ${error_description}`); return res.status(400).type('html').send(renderErrorPage( 'Authentication failed', error_description || 'The identity provider returned an error.', res.locals.cspNonce, )); }
if (!code || !state) { return res.status(400).send('Missing code or state'); }
// Get the saved state const savedState = db.getOAuthState(state); if (!savedState || savedState.app_id !== 'proxy-auth') { return res.status(400).send('Invalid or expired state'); }
// Exchange the AT Protocol code for tokens const callbackParams = new URLSearchParams(); callbackParams.set('code', code); callbackParams.set('state', state); if (iss) callbackParams.set('iss', iss);
const callbackResult = await oauthService.handleCallback( callbackParams, `${oidcIssuer}/auth/proxy/callback`, ); if (!callbackResult) { return res.status(500).send('Failed to complete authentication'); }
const { did, handle } = callbackResult; console.log(`[Proxy Callback] Authenticated user: ${handle} (${did})`);
// Get the original redirect URI from the proxy auth request const authRequestId = savedState.code_verifier; const authRequest = db.getProxyAuthRequest(authRequestId); if (!authRequest) { return res.status(400).send('Login request expired'); }
// Check access rules before creating session const accessResult = enforceAccess(did, handle, authRequest.redirect_uri); if (!accessResult.allowed) { db.deleteOAuthState(state); db.deleteProxyAuthRequest(authRequestId); return res.status(403).type('html').send(renderAccessDeniedPage(res.locals.cspNonce)); }
// Create a proxy session const now = Math.floor(Date.now() / 1000); const sessionId = crypto.randomBytes(32).toString('base64url'); db.createProxySession({ id: sessionId, did, handle, created_at: now, expires_at: now + forwardAuthConfig.sessionTtl, last_activity: now, user_agent: req.headers['user-agent'], ip_address: req.headers['x-forwarded-for'] as string || req.ip, });
// Set the ATAuth session cookie (on ATAuth domain) const sessionCookieValue = createSessionCookie(sessionId, secret, forwardAuthConfig.sessionTtl); res.setHeader('Set-Cookie', `${SESSION_COOKIE_NAME}=${sessionCookieValue}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${forwardAuthConfig.sessionTtl}`, );
// Generate auth ticket for the redirect target const targetOrigin = extractOrigin(authRequest.redirect_uri); if (!targetOrigin) { return res.status(400).send('Invalid redirect URI'); }
const ticket = createAuthTicket(sessionId, did, handle, targetOrigin, secret);
// Clean up db.deleteOAuthState(state); db.deleteProxyAuthRequest(authRequestId);
// Redirect back to the original URL with ticket const redirectUrl = new URL(authRequest.redirect_uri); redirectUrl.searchParams.set('_atauth_ticket', ticket); res.redirect(redirectUrl.toString()); } catch (error) { console.error('[Proxy Callback] Error:', error); res.status(500).type('html').send(renderErrorPage( 'Authentication error', 'An unexpected error occurred. Please try again.', res.locals.cspNonce, )); } });
// ===== GET /auth/proxy/logout ===== // Centralized logout. router.get('/proxy/logout', async (req: Request, res: Response) => { const cookies = parseCookies(req.headers.cookie); const sessionCookie = cookies[SESSION_COOKIE_NAME];
if (sessionCookie) { const sessionId = verifySessionCookie(sessionCookie, secret); if (sessionId) { db.deleteProxySession(sessionId); } }
// Clear the session cookie res.setHeader('Set-Cookie', `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`, );
const rd = req.query.rd as string; if (rd) { // Validate redirect against allowed origins to prevent open redirect const allowedOrigins = db.listProxyAllowedOrigins().map(o => o.origin); if (isAllowedRedirect(rd, allowedOrigins)) { return res.redirect(rd); } // If not allowed, fall through to the logged-out page }
res.type('html').send(renderLoggedOutPage(res.locals.cspNonce)); });
return router;}
// ===== HTML Templates =====
/** * Escape HTML special characters to prevent XSS. */function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''');}
function renderProxyLoginPage(authRequestId: string, nonce?: string, errorMessage?: string, passkeyEnabled = false): string { const errorHtml = errorMessage ? `<div class="error" style="display:block">${escapeHtml(errorMessage)}</div>` : '<div class="error" id="error"></div>'; return `<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Sign in - ATAuth</title> ${sharedStyles()}</head><body> <div class="container"> <div class="brand"><span class="brand-name">ATAuth</span></div> <h1>Sign in to continue</h1> <p class="subtitle">Authenticate with your Bluesky account to access this service</p> ${errorHtml} <form id="loginForm" action="/auth/proxy/login" method="POST"> <input type="hidden" name="auth_request_id" value="${escapeHtml(authRequestId)}"> <label for="handle">Your Handle</label> <input type="text" id="handle" name="handle" placeholder="you.bsky.social" autocomplete="username" autocapitalize="none" spellcheck="false" required> <p class="hint">Enter your Bluesky handle or custom domain</p> <button type="submit" id="submitBtn">Continue</button> </form>${passkeyEnabled ? ` <div class="divider"><span>or</span></div> <button type="button" class="passkey-btn" id="passkeyBtn" style="display:none"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 7a4 4 0 1 0-4 4"/><path d="M11 11l-1.5 1.5"/><path d="M9.5 12.5L6 16H4v2h2v-2h2v-2l1.5-1.5"/></svg> Sign in with passkey </button>` : ''} <p class="privacy">Bluesky will ask to authorize broad access -- this is a limitation of the AT Protocol OAuth standard. This gateway only reads your identity (handle). It will never post, follow, or access your data.</p> </div> <script${nonce ? ` nonce="${nonce}"` : ''}> var form = document.getElementById('loginForm'); var submitBtn = document.getElementById('submitBtn'); var errorDiv = document.getElementById('error'); var submitted = false; form.addEventListener('submit', function(e) { if (submitted) { e.preventDefault(); return; } submitted = true; submitBtn.disabled = true; submitBtn.textContent = 'Redirecting...'; if (errorDiv) errorDiv.style.display = 'none'; });${passkeyEnabled ? ` function b64urlToBuffer(s) { var b = s.replace(/-/g, '+').replace(/_/g, '/'); var pad = b.length % 4 === 0 ? '' : '='.repeat(4 - (b.length % 4)); var bin = atob(b + pad); var arr = new Uint8Array(bin.length); for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); return arr.buffer; } function bufferToB64url(buf) { var bytes = new Uint8Array(buf); var bin = ''; for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, ''); } function showError(msg) { var e = document.getElementById('error'); if (e) { e.textContent = msg; e.style.display = 'block'; } } var passkeyBtn = document.getElementById('passkeyBtn'); if (passkeyBtn && window.PublicKeyCredential) { passkeyBtn.style.display = 'flex'; passkeyBtn.addEventListener('click', function() { passkeyBtn.disabled = true; passkeyBtn.textContent = 'Authenticating...'; if (errorDiv) errorDiv.style.display = 'none'; fetch('/auth/passkey/authenticate/options', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }) .then(function(r) { return r.json(); }) .then(function(data) { var opts = data.options || data; var pubKeyOpts = { challenge: b64urlToBuffer(opts.challenge), timeout: opts.timeout, rpId: opts.rpId, userVerification: opts.userVerification }; if (opts.allowCredentials && opts.allowCredentials.length > 0) { pubKeyOpts.allowCredentials = opts.allowCredentials.map(function(c) { return { id: b64urlToBuffer(c.id), type: c.type, transports: c.transports }; }); } return navigator.credentials.get({ publicKey: pubKeyOpts }).then(function(cred) { return { cred: cred, challenge: opts.challenge }; }); }) .then(function(res) { var cred = res.cred; return fetch('/auth/proxy/passkey', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ auth_request_id: document.querySelector('input[name="auth_request_id"]').value, challenge: res.challenge, credential: { id: cred.id, rawId: bufferToB64url(cred.rawId), response: { clientDataJSON: bufferToB64url(cred.response.clientDataJSON), authenticatorData: bufferToB64url(cred.response.authenticatorData), signature: bufferToB64url(cred.response.signature), userHandle: cred.response.userHandle ? bufferToB64url(cred.response.userHandle) : undefined }, type: cred.type, authenticatorAttachment: cred.authenticatorAttachment } }) }); }) .then(function(r) { return r.json(); }) .then(function(result) { if (result.redirect_url) { window.location.href = result.redirect_url; } else { showError(result.error_description || 'Passkey authentication failed'); passkeyBtn.disabled = false; passkeyBtn.textContent = 'Sign in with passkey'; } }) .catch(function(err) { if (err.name !== 'NotAllowedError') { showError('Passkey authentication failed. Try signing in with your handle.'); } passkeyBtn.disabled = false; passkeyBtn.textContent = 'Sign in with passkey'; }); }); }` : ''} </script></body></html>`;}
function renderForbiddenPage(_nonce?: string): string { return `<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Access Denied - ATAuth</title> ${sharedStyles()}</head><body> <div class="container"> <h1>Access Denied</h1> <p class="subtitle">This service is not configured for SSO access. Contact your administrator.</p> </div></body></html>`;}
function renderAccessDeniedPage(_nonce?: string): string { return `<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Not Authorized - ATAuth</title> ${sharedStyles()}</head><body> <div class="container"> <h1>Not Authorized</h1> <p class="subtitle">Your account does not have access to this service. Contact your administrator if you believe this is an error.</p> </div></body></html>`;}
function renderErrorPage(title: string, message: string, _nonce?: string): string { return `<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${escapeHtml(title)} - ATAuth</title> ${sharedStyles()}</head><body> <div class="container"> <h1>${escapeHtml(title)}</h1> <p class="subtitle">${escapeHtml(message)}</p> </div></body></html>`;}
function renderLoggedOutPage(_nonce?: string): string { return `<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Signed Out - ATAuth</title> ${sharedStyles()}</head><body> <div class="container"> <h1>Signed Out</h1> <p class="subtitle">You have been signed out of ATAuth SSO.</p> </div></body></html>`;}
function sharedStyles(): string { return `<style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; background-image: radial-gradient(ellipse at 20% 50%, rgba(59, 130, 246, 0.15) 0%, transparent 50%), radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.12) 0%, transparent 50%), radial-gradient(ellipse at 50% 80%, rgba(14, 165, 233, 0.1) 0%, transparent 50%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; } .container { background: #1e293b; border: 1px solid #334155; border-radius: 16px; padding: 40px; width: 100%; max-width: 420px; box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.03); } .brand { text-align: center; margin-bottom: 28px; } .brand-name { font-size: 22px; font-weight: 700; letter-spacing: -0.5px; background: linear-gradient(135deg, #3b82f6, #8b5cf6); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } h1 { font-size: 22px; margin-bottom: 8px; color: #e2e8f0; font-weight: 600; } .subtitle { color: #94a3b8; margin-bottom: 28px; font-size: 14px; } label { display: block; font-weight: 500; margin-bottom: 8px; color: #cbd5e1; font-size: 14px; } input[type="text"] { width: 100%; padding: 14px 16px; background: #0f172a; border: 1px solid #334155; border-radius: 8px; font-size: 16px; color: #e2e8f0; transition: border-color 0.2s, box-shadow 0.2s; } input[type="text"]::placeholder { color: #475569; } input[type="text"]:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); } .hint { font-size: 12px; color: #64748b; margin-top: 8px; } button { width: 100%; padding: 14px; background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); color: white; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; margin-top: 24px; transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s; } button:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(59, 130, 246, 0.3); } button:active { transform: translateY(0); } button:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; } .error { background: rgba(127, 29, 29, 0.4); border: 1px solid rgba(252, 165, 165, 0.2); color: #fca5a5; padding: 12px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; display: none; } .divider { display: flex; align-items: center; margin: 24px 0; gap: 12px; } .divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: #334155; } .divider span { color: #64748b; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; } .passkey-btn { width: 100%; padding: 14px; background: transparent; color: #e2e8f0; border: 1px solid #334155; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; transition: border-color 0.2s, transform 0.15s, box-shadow 0.15s, background 0.2s; display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 0; } .passkey-btn:hover { border-color: #3b82f6; background: rgba(59, 130, 246, 0.06); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1); } .passkey-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; } .passkey-btn svg { width: 18px; height: 18px; } .privacy { font-size: 12px; color: #64748b; margin-top: 24px; line-height: 1.6; text-align: center; } .privacy strong { color: #94a3b8; } </style>`;}