Something went wrong. Try again.
AtAuth
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330/** * Auth Routes * * OAuth flow endpoints for AT Protocol authentication. * Express 5 automatically forwards async errors to the error handler. */
import { Router, Request, Response } from 'express';import { v4 as uuidv4 } from 'uuid';import { OAuthService } from '../services/oauth.js';import { DatabaseService } from '../services/database.js';import { createGatewayToken } from '../utils/hmac.js';import { httpError } from '../utils/errors.js';
/** * Validate a redirect URI against an app's allowed callback URL. * * SECURITY: Prevents open redirect attacks by ensuring the redirect_uri * matches the app's registered callback_url origin (or starts with it). * * @param redirectUri - The redirect URI from the request * @param allowedUrl - The app's registered callback_url * @returns true if valid, false otherwise */function isValidRedirectUri(redirectUri: string, allowedUrl: string | undefined): boolean { if (!redirectUri) return false;
try { const redirect = new URL(redirectUri); const protocol = redirect.protocol;
// Only allow HTTPS in production, with exceptions for: // - localhost/127.0.0.1 (development) // - .ts.net domains (Tailscale internal network - trusted) const isLocalhost = redirect.hostname === 'localhost' || redirect.hostname === '127.0.0.1'; const isTailscale = redirect.hostname.endsWith('.ts.net'); if (protocol !== 'https:' && !(protocol === 'http:' && (isLocalhost || isTailscale))) { return false; }
// If app has no registered callback, reject external redirects if (!allowedUrl) { return false; }
const allowed = new URL(allowedUrl);
// Must match origin (scheme + host + port) if (redirect.origin !== allowed.origin) { return false; }
// Redirect path must start with allowed path (for path restrictions) // This allows callback_url="https://app.com/auth" to accept // redirects to "https://app.com/auth/callback" but not "https://app.com/admin" if (allowed.pathname !== '/' && !redirect.pathname.startsWith(allowed.pathname)) { return false; }
return true; } catch { return false; }}
export function createAuthRoutes( db: DatabaseService, oauth: OAuthService): Router { const router = Router();
/** * POST /auth/init * Start OAuth flow for an application * * Body: * - app_id: The application identifier (required) * - handle: The user's AT Protocol handle (required) * - redirect_uri: Where to redirect after auth (optional) */ router.post('/init', async (req: Request, res: Response) => { const { app_id, handle, redirect_uri } = req.body;
if (!app_id || typeof app_id !== 'string') { throw httpError.badRequest('missing_app_id', 'app_id is required'); }
if (!handle || typeof handle !== 'string') { throw httpError.badRequest('missing_handle', 'handle is required (e.g., yourname.bsky.social)'); }
const app = db.getApp(app_id); if (!app) { throw httpError.notFound('app_not_found', `Application '${app_id}' is not registered`); }
// Validate redirect_uri if provided (used for post-auth redirect, not OAuth redirect_uri) let appRedirectUri: string | undefined; if (typeof redirect_uri === 'string') { if (!isValidRedirectUri(redirect_uri, app.callback_url)) { throw httpError.badRequest('invalid_redirect_uri', 'redirect_uri does not match the registered callback URL for this application'); } appRedirectUri = redirect_uri; } else if (app.callback_url) { appRedirectUri = app.callback_url; }
// Don't pass the app's redirect_uri to the AT Protocol OAuth client -- // the PDS must redirect back to ATAuth's own callback, not the downstream app. // The app's redirect_uri is stored in the OAuth state for post-auth redirect. const { url, state } = await oauth.generateAuthUrl( app_id, handle, undefined, appRedirectUri );
res.json({ auth_url: url, state, app_id, }); });
/** * GET /auth/callback * OAuth callback handler */ router.get('/callback', async (req: Request, res: Response) => { const params = new URLSearchParams(req.url.split('?')[1] || '');
const state = params.get('state'); if (!state) { throw httpError.badRequest('missing_state', 'OAuth state parameter is missing'); }
const savedState = db.getOAuthState(state); if (!savedState) { throw httpError.badRequest('invalid_state', 'OAuth state not found or expired'); }
// Check if this state belongs to an OIDC client — if so, forward to the // OIDC callback handler at /oauth/callback (don't delete state, OIDC handler needs it) const appCheck = db.getApp(savedState.app_id); if (appCheck && (appCheck as any).client_type === 'oidc') { const qs = req.url.includes('?') ? '?' + req.url.split('?')[1] : ''; return res.redirect(`/oauth/callback${qs}`); }
db.deleteOAuthState(state);
const app = appCheck; if (!app) { throw httpError.notFound('app_not_found', 'Application configuration not found'); }
const result = await oauth.handleCallback(params);
const existingMapping = db.getUserMapping(result.did, savedState.app_id); const userId = existingMapping?.user_id ?? null;
const token = createGatewayToken( { did: result.did, handle: result.handle, user_id: userId, app_id: savedState.app_id, }, app.hmac_secret, app.token_ttl_seconds );
const sessionId = uuidv4(); const expiresAt = new Date(Date.now() + app.token_ttl_seconds * 1000);
db.createSession({ id: sessionId, did: result.did, handle: result.handle, user_id: userId, app_id: savedState.app_id, expires_at: expiresAt, });
if (savedState.redirect_uri) { // Defense in depth: re-validate redirect URI before redirecting if (!isValidRedirectUri(savedState.redirect_uri, app.callback_url)) { console.error(`[SECURITY] Invalid redirect_uri in saved state: ${savedState.redirect_uri}`); throw httpError.badRequest('invalid_redirect_uri', 'Redirect URI validation failed'); }
const redirectUrl = new URL(savedState.redirect_uri); // Use URL fragment (hash) for sensitive data to prevent logging in: // - Server access logs // - Browser history // - Referrer headers // Fragments are only available client-side and never sent to servers const fragmentParams = new URLSearchParams(); fragmentParams.set('token', token); fragmentParams.set('session_id', sessionId); if (userId === null) { fragmentParams.set('needs_linking', 'true'); } redirectUrl.hash = fragmentParams.toString(); return res.redirect(redirectUrl.toString()); }
res.json({ token, session_id: sessionId, did: result.did, handle: result.handle, user_id: userId, needs_linking: userId === null, expires_at: expiresAt.toISOString(), }); });
/** * POST /auth/link * Link an AT Protocol identity to an application user account */ router.post('/link', async (req: Request, res: Response) => { const { session_id, user_id, app_id } = req.body;
if (!session_id || !user_id || !app_id) { throw httpError.badRequest('missing_params', 'session_id, user_id, and app_id are required'); }
const session = db.getSession(session_id); if (!session) { throw httpError.notFound('session_not_found', 'Session not found or expired'); }
if (session.app_id !== app_id) { throw httpError.badRequest('app_mismatch', 'Session app_id does not match'); }
const app = db.getApp(app_id); if (!app) { throw httpError.notFound('app_not_found', 'Application not found'); }
db.setUserMapping({ did: session.did, app_id, user_id: parseInt(user_id, 10), handle: session.handle, });
const token = createGatewayToken( { did: session.did, handle: session.handle, user_id: parseInt(user_id, 10), app_id, }, app.hmac_secret, app.token_ttl_seconds );
res.json({ success: true, token, did: session.did, handle: session.handle, user_id: parseInt(user_id, 10), }); });
/** * POST /auth/refresh * Refresh an expired gateway token */ router.post('/refresh', async (req: Request, res: Response) => { const { session_id, app_id } = req.body;
if (!session_id || !app_id) { throw httpError.badRequest('missing_params', 'session_id and app_id are required'); }
const session = db.getSession(session_id); if (!session) { throw httpError.notFound('session_not_found', 'Session not found or expired'); }
const app = db.getApp(app_id); if (!app) { throw httpError.notFound('app_not_found', 'Application not found'); }
const mapping = db.getUserMapping(session.did, app_id); const userId = mapping?.user_id ?? session.user_id;
const token = createGatewayToken( { did: session.did, handle: session.handle, user_id: userId, app_id, }, app.hmac_secret, app.token_ttl_seconds );
res.json({ token, expires_in: app.token_ttl_seconds, }); });
/** * POST /auth/logout * Invalidate a session */ router.post('/logout', async (req: Request, res: Response) => { const { session_id } = req.body;
if (!session_id) { throw httpError.badRequest('missing_session_id', 'session_id is required'); }
db.deleteSession(session_id);
res.json({ success: true }); });
return router;}