27 lines
4.3 KiB
JavaScript
27 lines
4.3 KiB
JavaScript
import express from 'express';
|
|
import * as oidc from 'openid-client';
|
|
import {sessionCodec} from './auth.mjs';
|
|
import {fileURLToPath} from 'node:url';
|
|
const origin=process.env.APP_ORIGIN,issuer=process.env.KEYCLOAK_ISSUER,clientId=process.env.KEYCLOAK_CLIENT_ID,secret=process.env.KEYCLOAK_CLIENT_SECRET;
|
|
if(!origin||!issuer||!clientId)throw new Error('APP_ORIGIN and Keycloak configuration are required');
|
|
if(!origin.startsWith('https://')||!issuer.startsWith('https://'))throw new Error('HTTPS is required');
|
|
const codec=sessionCodec(secret,clientId);const app=express();app.disable('x-powered-by');app.set('trust proxy',1);app.use(express.urlencoded({extended:false,limit:'2kb'}));
|
|
let config;async function provider(){return config??=await oidc.discovery(new URL(issuer),clientId,secret)}
|
|
const cookie=(res,name,value,seconds)=>res.append('Set-Cookie',`${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${seconds}`);
|
|
const readCookie=(req,name)=>{const raw=(req.headers.cookie||'').split(';').map(x=>x.trim()).find(x=>x.startsWith(`${name}=`));return raw?raw.slice(name.length+1):''};
|
|
app.use((req,res,next)=>{res.set({'X-Content-Type-Options':'nosniff','Referrer-Policy':'same-origin','Content-Security-Policy':"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",'Cache-Control':'no-store'});next()});
|
|
app.get('/health',(_,res)=>res.json({status:'ok',version:'0.1.0',revision:process.env.SOURCE_COMMIT||'unknown'}));
|
|
app.get('/auth/login',async(req,res,next)=>{try{const pkce=oidc.randomPKCECodeVerifier(),state=oidc.randomState(),nonce=oidc.randomNonce();cookie(res,'__Host-character-flow',await codec.seal({pkce,state,nonce},600),600);const url=oidc.buildAuthorizationUrl(await provider(),{redirect_uri:`${origin}/auth/callback`,scope:'openid profile',state,nonce,code_challenge:await oidc.calculatePKCECodeChallenge(pkce),code_challenge_method:'S256'});res.redirect(url.href)}catch(e){next(e)}});
|
|
app.get('/auth/callback',async(req,res,next)=>{try{const flow=await codec.open(readCookie(req,'__Host-character-flow'));cookie(res,'__Host-character-flow','',0);if(!flow)return res.status(400).send('Sign-in expired. Start again at /auth/login.');const url=new URL(req.originalUrl,origin);const tokens=await oidc.authorizationCodeGrant(await provider(),url,{pkceCodeVerifier:flow.pkce,expectedState:flow.state,expectedNonce:flow.nonce});const claims=tokens.claims();if(!claims?.sub)throw new Error('Missing identity');cookie(res,'__Host-character-session',await codec.seal({sub:claims.sub,name:claims.name||claims.preferred_username||'Adventurer'}),43200);res.redirect('/')}catch(e){next(e)}});
|
|
app.use(async(req,res,next)=>{req.identity=await codec.open(readCookie(req,'__Host-character-session'));next()});
|
|
app.get('/api/session',(req,res)=>req.identity?res.json({sub:req.identity.sub,name:req.identity.name,expiresAt:req.identity.exp*1000}):res.status(401).json({error:'Sign in required'}));
|
|
app.post('/auth/logout',(req,res)=>{if(req.get('origin')!==origin)return res.sendStatus(403);cookie(res,'__Host-character-session','',0);cookie(res,'__Host-character-flow','',0);res.redirect('/auth/signed-out')});
|
|
app.get('/auth/signed-out',(_,res)=>res.type('html').send('<!doctype html><meta name="viewport" content="width=device-width"><title>Signed out</title><h1>Signed out of Character</h1><p>Your character saves remain on this device.</p><a href="/auth/login">Sign in with Arrowstack</a>'));
|
|
const dist=fileURLToPath(new URL('../dist/',import.meta.url));
|
|
app.use((req,res,next)=>{if(['/sw.js','/manifest.webmanifest','/icon-192.png','/icon-512.png'].includes(req.path)||req.path.startsWith('/assets/'))return next();if(!req.identity)return res.redirect('/auth/login');next()});
|
|
app.use(express.static(dist,{index:false,setHeaders:res=>res.set('Cache-Control','private, no-cache')}));
|
|
app.get('/',(_,res)=>res.sendFile(`${dist}/index.html`));
|
|
app.use((req,res)=>res.sendStatus(404));
|
|
app.use((error,req,res,next)=>{console.error('Request failed:',error.code||error.name);res.status(503).send('Sign-in could not be completed. Please try /auth/login again.');});
|
|
app.listen(Number(process.env.PORT||8080),'0.0.0.0',()=>console.log('Character PWA listening on 8080'));
|