92 lines
7.0 KiB
JavaScript
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();
|
|
};
|
|
}
|