import { createHash, timingSafeEqual } from 'node:crypto'; import { createRemoteJWKSet, jwtVerify, customFetch } from 'jose'; import { digest, token } from './store.mjs'; import { normalizePrincipal,principalForGroups,adminRoutePermission,bootstrapAllowed } from './admin-access.mjs'; export function authRoutes(app, store, config) { const {db} = store; const oidcFetch=config.oidcFetch||fetch; let discovery; async function metadata() { if(!config.oidcIssuer || !config.oidcClientId) throw Error('SSO is not configured'); if(!discovery) { const issuer=config.oidcIssuer.replace(/\/$/,''); if(new URL(issuer).protocol!=='https:') throw Error('OIDC issuer requires HTTPS'); const response=await oidcFetch(`${issuer}/.well-known/openid-configuration`,{redirect:'error',signal:AbortSignal.timeout(10000)}); if(!response.ok) throw Error('OIDC discovery failed'); const m=await response.json(); if(m.issuer!==config.oidcIssuer || ['authorization_endpoint','token_endpoint','jwks_uri'].some(k=>new URL(m[k]).protocol!=='https:')) throw Error('OIDC metadata rejected'); discovery=m; } return discovery; } app.get('/auth/login',async(req,res,next)=>{ try { const m=await metadata();const state=token(),verifier=token(),nonce=token(); const ticket=typeof req.query.ticket==='string'?req.query.ticket:''; if(ticket && !db.prepare('SELECT id FROM tickets WHERE id=? AND expires>?').get(ticket,Date.now())) return res.status(400).send('Invalid device login'); db.prepare('INSERT INTO logins VALUES(?,?,?,?,?)').run(state,Date.now()+300000,verifier,nonce,ticket); res.cookie('sn_login',state,{httpOnly:true,secure:config.secure,sameSite:'lax',maxAge:300000,path:'/auth'}); const url=new URL(m.authorization_endpoint); for(const [k,v] of Object.entries({client_id:config.oidcClientId,response_type:'code',scope:'openid profile email',redirect_uri:`${config.publicUrl}/auth/callback`,state,nonce,code_challenge:createHash('sha256').update(verifier).digest('base64url'),code_challenge_method:'S256'})) url.searchParams.set(k,v); res.redirect(url.toString()); } catch(e){next(e);} }); app.get('/auth/callback',async(req,res,next)=>{ try { const state=typeof req.query.state==='string'?req.query.state:''; if(cookie(req,'sn_login')!==state || !state) return res.status(400).send('Login state rejected'); const login=db.prepare('SELECT * FROM logins WHERE state=? AND expires>?').get(state,Date.now()); if(!login || typeof req.query.code!=='string') return res.status(400).send('Login expired'); db.prepare('DELETE FROM logins WHERE state=?').run(state); res.clearCookie('sn_login',{path:'/auth'}); const m=await metadata(); const form=new URLSearchParams({grant_type:'authorization_code',code:req.query.code,client_id:config.oidcClientId,redirect_uri:`${config.publicUrl}/auth/callback`,code_verifier:login.verifier}); const headers={'content-type':'application/x-www-form-urlencoded'}; if(config.oidcClientSecret) { headers.authorization=`Basic ${Buffer.from(`${encodeURIComponent(config.oidcClientId)}:${encodeURIComponent(config.oidcClientSecret)}`).toString('base64')}`; } const r=await oidcFetch(m.token_endpoint,{method:'POST',redirect:'error',headers,body:form,signal:AbortSignal.timeout(10000)}); if(!r.ok) throw Error('Token exchange failed'); const result=await r.json(); const {payload}=await jwtVerify(result.id_token,createRemoteJWKSet(new URL(m.jwks_uri),{[customFetch]:oidcFetch}),{issuer:config.oidcIssuer,audience:config.oidcClientId,algorithms:['RS256','ES256','PS256','EdDSA'],requiredClaims:['exp','iat','sub','nonce']}); if(payload.nonce!==login.nonce || (payload.azp && payload.azp!==config.oidcClientId)) throw Error('Identity claims rejected'); const groups=payload[config.oidcGroupsClaim]; const principal=principalForGroups({sub:payload.sub,name:typeof payload.name==='string'?payload.name:payload.sub},groups,config); if(login.ticket) { if(config.oidcDeviceGroup&&(!Array.isArray(groups)||!groups.includes(config.oidcDeviceGroup)))return res.status(403).send('Your account is not authorized for browser enrollment.'); const ticket=db.prepare('SELECT * FROM tickets WHERE id=? AND expires>?').get(login.ticket,Date.now()); if(!ticket || ticket.credential) return res.status(400).send('Device login expired or already authorized'); db.prepare('UPDATE tickets SET credential=? WHERE id=?').run(JSON.stringify(principal),login.ticket); store.event(null,'sso.device-authorized',{actor:principal.sub}); return res.type('text').send('Signed in. Return to SafetyNet to complete device enrollment.'); } if(!principal.role) return res.status(403).send('Your account is not in an authorized management group.'); const credential=token();db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+8*3600000,JSON.stringify(principal)); res.cookie('sn_session',credential,{httpOnly:true,secure:config.secure,sameSite:'lax',maxAge:8*3600000,path:'/'}); store.event(null,'sso.admin-login',{actor:principal.sub});res.redirect('/'); }catch(e){next(e);} }); app.post('/auth/logout',(req,res)=>{const s=cookie(req,'sn_session');if(s&&req.headers.origin!==config.publicUrl)return res.status(403).json({error:'Same-origin logout required'});if(s)db.prepare('DELETE FROM sessions WHERE hash=?').run(digest(s));res.clearCookie('sn_session',{path:'/'});res.json({ok:true});}); } export function cookie(req,name) { const part=(req.headers.cookie||'').split(';').map(v=>v.trim()).find(v=>v.startsWith(`${name}=`)); return part?part.slice(name.length+1):''; } export function adminMiddleware(store,config) { return (req,res,next)=>{ const supplied=(req.headers.authorization||'').replace(/^Bearer /,''); let principal,cookieAuthenticated=false; if(bootstrapAllowed(config)&&supplied && config.adminToken && timingSafeEqual(Buffer.from(digest(supplied)),Buffer.from(digest(config.adminToken))))principal=normalizePrincipal({sub:'bootstrap-admin',admin:true}); else { const session=store.db.prepare('SELECT principal FROM sessions WHERE hash=? AND expires>?').get(digest(cookie(req,'sn_session')),Date.now()); if(session){principal=normalizePrincipal(JSON.parse(session.principal));cookieAuthenticated=true;} } if(!principal)return res.status(401).json({error:'Administrator authentication required'}); if(cookieAuthenticated&&!['GET','HEAD','OPTIONS'].includes(req.method)&&req.headers.origin!==config.publicUrl)return res.status(403).json({error:'Same-origin management request required'}); let permission=adminRoutePermission(req.method,req.originalUrl); if(config.requireIndependentPolicyApproval&&permission==='policy.write'&&!req.originalUrl.startsWith('/api/admin/policy-reviews/'))permission='policy.prepare'; if(!permission||!principal.permissions.includes(permission))return res.status(403).json({error:'Management permission required',permission}); req.principal=principal;next(); }; }