Files
safetynet/apps/server/auth.mjs
T
2026-10-13 10:34:48 +02:00

92 lines
7.0 KiB
JavaScript

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();
};
}