Security hardening: low-risk easy wins
- JWT_SECRET fails fast at startup in production
- CORS fails closed if APP_URL + CORS_ORIGINS are both missing
- Explicit HSTS (1y, includeSubDomains, preload)
- Rate limit sensitive auth endpoints (change-password, 2FA)
- /api/health now returns {ok:true}; details gated behind admin auth
- Login enumeration removed — generic 401 + dummy bcrypt on miss
- ReDoS guard: 20KB input cap on /suggest-codes
- showToast uses textContent, no innerHTML
- clearSession() clears service worker caches on logout
- OIDC state is now HMAC-signed and stateless (survives restart)
- SSRF guard on admin-set OIDC issuer (blocks private IPs, requires HTTPS)
Adds docs/mobile-build.md covering APK build, release, git push,
keystore, and troubleshooting for both PedScribe and PedsHub apps.
This commit is contained in:
parent
2f2948339c
commit
cf0af53dd4
8 changed files with 239 additions and 35 deletions
115
docs/mobile-build.md
Normal file
115
docs/mobile-build.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# Mobile App Build & Release
|
||||||
|
|
||||||
|
Capacitor wrappers for **PedScribe** (this repo) and **PedsHub Quiz**
|
||||||
|
(`/home/danvics/docker/quiz`). Both ship as Android APKs and iOS builds.
|
||||||
|
|
||||||
|
## One-time setup
|
||||||
|
|
||||||
|
- **Keystore** (reused for both apps):
|
||||||
|
```bash
|
||||||
|
keytool -genkeypair -v -keystore ~/pedscribe-release.jks \
|
||||||
|
-keyalg RSA -keysize 2048 -validity 10000 -alias pedscribe
|
||||||
|
```
|
||||||
|
Store the password somewhere safe — losing it means rotating signing keys.
|
||||||
|
|
||||||
|
- **Android Studio path** (required when you want to open the IDE):
|
||||||
|
```bash
|
||||||
|
export CAPACITOR_ANDROID_STUDIO_PATH="/snap/android-studio/209/bin/studio.sh"
|
||||||
|
```
|
||||||
|
Put it in your `~/.bashrc` if you want it permanent.
|
||||||
|
|
||||||
|
## Release build — PedScribe
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/danvics/docker/ped-ai/mobile
|
||||||
|
npm install # picks up any new plugins
|
||||||
|
npx cap sync android # copies web assets + plugin glue
|
||||||
|
cd android
|
||||||
|
./gradlew assembleRelease \
|
||||||
|
-Pandroid.injected.signing.store.file=$HOME/pedscribe-release.jks \
|
||||||
|
-Pandroid.injected.signing.store.password=YOUR_KEYSTORE_PASSWORD \
|
||||||
|
-Pandroid.injected.signing.key.alias=pedscribe \
|
||||||
|
-Pandroid.injected.signing.key.password=YOUR_KEY_PASSWORD
|
||||||
|
# APK lands at: android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release build — PedsHub Quiz
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/danvics/docker/quiz/mobile
|
||||||
|
npm install
|
||||||
|
npx cap sync android
|
||||||
|
cd android
|
||||||
|
./gradlew assembleRelease \
|
||||||
|
-Pandroid.injected.signing.store.file=$HOME/pedscribe-release.jks \
|
||||||
|
-Pandroid.injected.signing.store.password=YOUR_KEYSTORE_PASSWORD \
|
||||||
|
-Pandroid.injected.signing.key.alias=pedscribe \
|
||||||
|
-Pandroid.injected.signing.key.password=YOUR_KEY_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publish APK on GitHub Releases
|
||||||
|
|
||||||
|
The login page links to `github.com/<owner>/<repo>/releases/latest` (see
|
||||||
|
`public/index.html` around line 114). Publishing a tagged release updates the
|
||||||
|
download link automatically — no site redeploy needed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/danvics/docker/ped-ai
|
||||||
|
gh release create v6.0.1 \
|
||||||
|
mobile/android/app/build/outputs/apk/release/app-release.apk \
|
||||||
|
--title "PedScribe 6.0.1" \
|
||||||
|
--notes "Hardware-backed secure storage for auth token on mobile."
|
||||||
|
```
|
||||||
|
|
||||||
|
For the quiz app:
|
||||||
|
```bash
|
||||||
|
cd /home/danvics/docker/quiz
|
||||||
|
gh release create v1.0.0 \
|
||||||
|
mobile/android/app/build/outputs/apk/release/app-release.apk \
|
||||||
|
--title "PedsHub 1.0.0" \
|
||||||
|
--notes "Initial Android release."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Push source changes to git
|
||||||
|
|
||||||
|
Standard flow — the mobile project lives alongside the web app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/danvics/docker/ped-ai
|
||||||
|
git add mobile/ public/ src/
|
||||||
|
git commit -m "Your message"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
Same for quiz at `/home/danvics/docker/quiz`.
|
||||||
|
|
||||||
|
## iOS
|
||||||
|
|
||||||
|
iOS requires macOS + Xcode. On Linux the sync still runs but you cannot build
|
||||||
|
the `.ipa`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/danvics/docker/ped-ai/mobile
|
||||||
|
npx cap sync ios
|
||||||
|
# Then on a Mac: open ios/App/App.xcworkspace and Archive.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reinstall on device after rebuild
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb install -r android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
`-r` preserves app data (saved server URL, cached sessions).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **`npx cap` can't find the project** — you must `cd mobile/` first, not run
|
||||||
|
from the repo root.
|
||||||
|
- **`Keystore was tampered with`** — wrong password. Do not generate a new
|
||||||
|
keystore unless you are ready to rotate the signing identity on Play Store.
|
||||||
|
- **Microphone "denied" in the app** — open system settings, long-press the
|
||||||
|
app icon → App info → Permissions → Microphone → Allow. Web-side prompt
|
||||||
|
does not always surface because the native layer intercepts it.
|
||||||
|
- **Foreground recording stops on newer Android** — the service must declare
|
||||||
|
`foregroundServiceType="microphone"` in `AndroidManifest.xml`.
|
||||||
|
|
@ -516,7 +516,10 @@ function showToast(message, type) {
|
||||||
var toast = document.createElement('div');
|
var toast = document.createElement('div');
|
||||||
toast.className = 'toast toast-' + (type || 'success');
|
toast.className = 'toast toast-' + (type || 'success');
|
||||||
var icon = type === 'error' ? 'exclamation-circle' : type === 'info' ? 'info-circle' : 'check-circle';
|
var icon = type === 'error' ? 'exclamation-circle' : type === 'info' ? 'info-circle' : 'check-circle';
|
||||||
toast.innerHTML = '<i class="fas fa-' + icon + '"></i> ' + message;
|
var iconEl = document.createElement('i');
|
||||||
|
iconEl.className = 'fas fa-' + icon;
|
||||||
|
toast.appendChild(iconEl);
|
||||||
|
toast.appendChild(document.createTextNode(' ' + String(message == null ? '' : message)));
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
setTimeout(function() { toast.remove(); }, 3500);
|
setTimeout(function() { toast.remove(); }, 3500);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
localStorage.removeItem(SESSION_KEY);
|
localStorage.removeItem(SESSION_KEY);
|
||||||
}
|
}
|
||||||
window.AUTH_TOKEN = null;
|
window.AUTH_TOKEN = null;
|
||||||
|
// Clear service worker caches so a logged-out user on a shared device
|
||||||
|
// cannot read cached pages from offline mode.
|
||||||
|
try {
|
||||||
|
if (window.caches && caches.keys) {
|
||||||
|
caches.keys().then(function(names) {
|
||||||
|
names.forEach(function(n) { caches.delete(n); });
|
||||||
|
}).catch(function(){});
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.getAuthHeaders = function() {
|
window.getAuthHeaders = function() {
|
||||||
|
|
|
||||||
27
server.js
27
server.js
|
|
@ -19,6 +19,7 @@ app.set('trust proxy', 1);
|
||||||
// ============================================================
|
// ============================================================
|
||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
crossOriginEmbedderPolicy: false,
|
crossOriginEmbedderPolicy: false,
|
||||||
|
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
directives: {
|
directives: {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
|
|
@ -53,12 +54,17 @@ app.use(helmet({
|
||||||
var allowedOrigins = [];
|
var allowedOrigins = [];
|
||||||
if (process.env.APP_URL) allowedOrigins.push(process.env.APP_URL.replace(/\/$/, ''));
|
if (process.env.APP_URL) allowedOrigins.push(process.env.APP_URL.replace(/\/$/, ''));
|
||||||
if (process.env.CORS_ORIGINS) process.env.CORS_ORIGINS.split(',').forEach(function(o) { allowedOrigins.push(o.trim().replace(/\/$/, '')); });
|
if (process.env.CORS_ORIGINS) process.env.CORS_ORIGINS.split(',').forEach(function(o) { allowedOrigins.push(o.trim().replace(/\/$/, '')); });
|
||||||
|
var IS_PROD = process.env.NODE_ENV === 'production' || !!process.env.APP_URL;
|
||||||
|
if (IS_PROD && allowedOrigins.length === 0) {
|
||||||
|
console.error('[FATAL] Production mode but no APP_URL or CORS_ORIGINS set. Refusing to run with open CORS.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: function(origin, callback) {
|
origin: function(origin, callback) {
|
||||||
// Allow requests with no origin (mobile apps, curl, server-to-server)
|
// Allow requests with no origin (mobile apps, curl, server-to-server)
|
||||||
if (!origin) return callback(null, true);
|
if (!origin) return callback(null, true);
|
||||||
// In development (no origins configured) allow all
|
// Development only: if no origins configured, allow all
|
||||||
if (allowedOrigins.length === 0) return callback(null, true);
|
if (!IS_PROD && allowedOrigins.length === 0) return callback(null, true);
|
||||||
if (allowedOrigins.indexOf(origin) !== -1) return callback(null, true);
|
if (allowedOrigins.indexOf(origin) !== -1) return callback(null, true);
|
||||||
callback(new Error('CORS: origin not allowed'));
|
callback(new Error('CORS: origin not allowed'));
|
||||||
},
|
},
|
||||||
|
|
@ -105,6 +111,17 @@ app.use('/api/auth/resend-verification', rateLimit({
|
||||||
message: { error: 'Too many verification requests. Try again in 15 minutes.' },
|
message: { error: 'Too many verification requests. Try again in 15 minutes.' },
|
||||||
standardHeaders: true, legacyHeaders: false
|
standardHeaders: true, legacyHeaders: false
|
||||||
}));
|
}));
|
||||||
|
// Post-login sensitive endpoints — protect against brute force with a stolen cookie.
|
||||||
|
// Keyed per-IP; 20 attempts / 15 min covers legitimate use, blocks TOTP brute force (10^6 space).
|
||||||
|
var sensitiveAuthLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, max: 20,
|
||||||
|
message: { error: 'Too many attempts. Try again in 15 minutes.' },
|
||||||
|
standardHeaders: true, legacyHeaders: false
|
||||||
|
});
|
||||||
|
app.use('/api/auth/change-password', sensitiveAuthLimiter);
|
||||||
|
app.use('/api/auth/setup-2fa', sensitiveAuthLimiter);
|
||||||
|
app.use('/api/auth/verify-2fa', sensitiveAuthLimiter);
|
||||||
|
app.use('/api/auth/disable-2fa', sensitiveAuthLimiter);
|
||||||
|
|
||||||
// Serve .well-known/assetlinks.json for TWA verification (must be before static)
|
// Serve .well-known/assetlinks.json for TWA verification (must be before static)
|
||||||
app.get('/.well-known/assetlinks.json', (req, res) => {
|
app.get('/.well-known/assetlinks.json', (req, res) => {
|
||||||
|
|
@ -158,7 +175,13 @@ app.get('/api/models', async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const { activeProvider } = require('./src/utils/ai');
|
const { activeProvider } = require('./src/utils/ai');
|
||||||
|
// Public health: minimal — just confirm the server is up.
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
// Detailed health: admin-only — exposes which providers are configured.
|
||||||
|
const { authMiddleware: _hcAuth, adminMiddleware: _hcAdmin } = require('./src/middleware/auth');
|
||||||
|
app.get('/api/health/detailed', _hcAuth, _hcAdmin, (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'running', version: '6.0.0', provider: activeProvider,
|
status: 'running', version: '6.0.0', provider: activeProvider,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,13 @@ const jwt = require('jsonwebtoken');
|
||||||
const db = require('../db/database');
|
const db = require('../db/database');
|
||||||
const { hashToken } = require('../utils/sessions');
|
const { hashToken } = require('../utils/sessions');
|
||||||
|
|
||||||
|
if (!process.env.JWT_SECRET) {
|
||||||
|
if (process.env.NODE_ENV === 'production' || process.env.APP_URL) {
|
||||||
|
console.error('[FATAL] JWT_SECRET is not set. Refusing to start in production.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.warn('[WARN] JWT_SECRET not set — using development fallback. DO NOT use in production.');
|
||||||
|
}
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
||||||
|
|
||||||
async function authMiddleware(req, res, next) {
|
async function authMiddleware(req, res, next) {
|
||||||
|
|
|
||||||
|
|
@ -300,17 +300,23 @@ router.post('/login', async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
|
var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||||
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
// Enumeration-resistant: always run bcrypt to keep timing constant, and return
|
||||||
|
// the same generic message for unknown-user, wrong-password, disabled, unverified.
|
||||||
if (user.disabled) {
|
var DUMMY_HASH = '$2b$12$CwTycUXWue0Thq9StjUM0uJ8.aDLA18dY6nB5xuWz5M6l8lR6rYS.';
|
||||||
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
|
if (!user) {
|
||||||
[user.id, 'login_blocked', req.ip, 'Account disabled']);
|
await bcrypt.compare(password, DUMMY_HASH).catch(function(){});
|
||||||
return res.status(403).json({ error: 'Account disabled. Contact an administrator.' });
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
|
|
||||||
var valid = await bcrypt.compare(password, user.password);
|
var valid = await bcrypt.compare(password, user.password);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login_failed', req.ip]);
|
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login_failed', req.ip]).catch(function(){});
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.disabled) {
|
||||||
|
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
|
||||||
|
[user.id, 'login_blocked', req.ip, 'Account disabled']).catch(function(){});
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,10 @@ router.post('/suggest-codes', authMiddleware, async function(req, res) {
|
||||||
try {
|
try {
|
||||||
var { noteText, noteType, patientAge, visitType } = req.body;
|
var { noteText, noteType, patientAge, visitType } = req.body;
|
||||||
if (!noteText) return res.status(400).json({ error: 'No note text provided' });
|
if (!noteText) return res.status(400).json({ error: 'No note text provided' });
|
||||||
|
// ReDoS guard — regex over note text can blow up on pathological inputs.
|
||||||
|
if (typeof noteText !== 'string' || noteText.length > 20000) {
|
||||||
|
return res.status(400).json({ error: 'Note too long for billing analysis (max 20,000 chars)' });
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Extract diagnoses from note text
|
// 1. Extract diagnoses from note text
|
||||||
var diagnoses = extractDiagnoses(noteText);
|
var diagnoses = extractDiagnoses(noteText);
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,62 @@ var express = require('express');
|
||||||
var router = express.Router();
|
var router = express.Router();
|
||||||
var crypto = require('crypto');
|
var crypto = require('crypto');
|
||||||
var jwt = require('jsonwebtoken');
|
var jwt = require('jsonwebtoken');
|
||||||
|
var dns = require('dns').promises;
|
||||||
|
var net = require('net');
|
||||||
var db = require('../db/database');
|
var db = require('../db/database');
|
||||||
var { JWT_SECRET, authMiddleware, adminMiddleware } = require('../middleware/auth');
|
var { JWT_SECRET, authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||||
var { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
|
var { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
|
||||||
|
|
||||||
// In-memory OIDC state store (short-lived, no DB needed)
|
// SSRF guard: reject issuer URLs that resolve to private / loopback / link-local IPs.
|
||||||
var pendingStates = {};
|
// Prevents an admin (or compromised admin account) from pointing OIDC at AWS metadata etc.
|
||||||
|
function isPrivateIp(ip) {
|
||||||
|
if (!ip) return true;
|
||||||
|
if (net.isIPv4(ip)) {
|
||||||
|
var p = ip.split('.').map(Number);
|
||||||
|
if (p[0] === 10) return true;
|
||||||
|
if (p[0] === 127) return true;
|
||||||
|
if (p[0] === 0) return true;
|
||||||
|
if (p[0] === 169 && p[1] === 254) return true;
|
||||||
|
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;
|
||||||
|
if (p[0] === 192 && p[1] === 168) return true;
|
||||||
|
if (p[0] >= 224) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (net.isIPv6(ip)) {
|
||||||
|
var low = ip.toLowerCase();
|
||||||
|
if (low === '::1' || low === '::' ) return true;
|
||||||
|
if (low.startsWith('fe80:') || low.startsWith('fc') || low.startsWith('fd')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
async function assertSafeIssuer(urlStr) {
|
||||||
|
var u = new URL(urlStr);
|
||||||
|
if (u.protocol !== 'https:') throw new Error('OIDC issuer must use https://');
|
||||||
|
var addrs = await dns.lookup(u.hostname, { all: true });
|
||||||
|
for (var i = 0; i < addrs.length; i++) {
|
||||||
|
if (isPrivateIp(addrs[i].address)) throw new Error('OIDC issuer resolves to a private IP (blocked)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clean expired states every 10 minutes
|
// Signed, stateless OIDC state — survives restart / scales to multiple processes.
|
||||||
setInterval(function() {
|
function signState(payload) {
|
||||||
var now = Date.now();
|
var body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
Object.keys(pendingStates).forEach(function(k) {
|
var sig = crypto.createHmac('sha256', JWT_SECRET).update(body).digest('base64url');
|
||||||
if (pendingStates[k].expires < now) delete pendingStates[k];
|
return body + '.' + sig;
|
||||||
});
|
}
|
||||||
}, 10 * 60 * 1000);
|
function verifyState(token) {
|
||||||
|
if (!token || typeof token !== 'string') return null;
|
||||||
|
var parts = token.split('.');
|
||||||
|
if (parts.length !== 2) return null;
|
||||||
|
var expected = crypto.createHmac('sha256', JWT_SECRET).update(parts[0]).digest('base64url');
|
||||||
|
if (!crypto.timingSafeEqual(Buffer.from(parts[1]), Buffer.from(expected))) return null;
|
||||||
|
try {
|
||||||
|
var payload = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
|
||||||
|
if (!payload.expires || payload.expires < Date.now()) return null;
|
||||||
|
return payload;
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
function setAuthCookie(res, token) {
|
function setAuthCookie(res, token) {
|
||||||
var isProduction = process.env.NODE_ENV === 'production' || process.env.APP_URL;
|
var isProduction = process.env.NODE_ENV === 'production' || process.env.APP_URL;
|
||||||
|
|
@ -69,19 +111,19 @@ router.get('/oidc', async function(req, res) {
|
||||||
var appUrl = (process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, '');
|
var appUrl = (process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||||
var redirectUri = appUrl + '/api/auth/oidc/callback';
|
var redirectUri = appUrl + '/api/auth/oidc/callback';
|
||||||
|
|
||||||
|
await assertSafeIssuer(issuer);
|
||||||
var config = await oidc.discovery(new URL(issuer), clientId);
|
var config = await oidc.discovery(new URL(issuer), clientId);
|
||||||
|
|
||||||
var state = crypto.randomBytes(24).toString('hex');
|
|
||||||
var nonce = crypto.randomBytes(24).toString('hex');
|
var nonce = crypto.randomBytes(24).toString('hex');
|
||||||
var codeVerifier = oidc.randomPKCECodeVerifier();
|
var codeVerifier = oidc.randomPKCECodeVerifier();
|
||||||
var codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
var codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
||||||
|
|
||||||
// Store state for callback verification (5 min TTL)
|
// Stateless signed state — survives restart, works across multiple processes.
|
||||||
pendingStates[state] = {
|
var state = signState({
|
||||||
nonce: nonce,
|
n: nonce,
|
||||||
codeVerifier: codeVerifier,
|
v: codeVerifier,
|
||||||
expires: Date.now() + 5 * 60 * 1000
|
expires: Date.now() + 5 * 60 * 1000
|
||||||
};
|
});
|
||||||
|
|
||||||
var authUrl = oidc.buildAuthorizationUrl(config, {
|
var authUrl = oidc.buildAuthorizationUrl(config, {
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
|
|
@ -105,17 +147,11 @@ router.get('/oidc/callback', async function(req, res) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var state = req.query.state;
|
var state = req.query.state;
|
||||||
if (!state || !pendingStates[state]) {
|
var pending = verifyState(state);
|
||||||
|
if (!pending) {
|
||||||
return res.redirect(appUrl + '?error=invalid_state');
|
return res.redirect(appUrl + '?error=invalid_state');
|
||||||
}
|
}
|
||||||
|
|
||||||
var pending = pendingStates[state];
|
|
||||||
delete pendingStates[state];
|
|
||||||
|
|
||||||
if (pending.expires < Date.now()) {
|
|
||||||
return res.redirect(appUrl + '?error=expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
var issuer = await db.getSetting('oidc.issuer');
|
var issuer = await db.getSetting('oidc.issuer');
|
||||||
var clientId = await db.getSetting('oidc.client_id');
|
var clientId = await db.getSetting('oidc.client_id');
|
||||||
var clientSecret = await db.getSetting('oidc.client_secret');
|
var clientSecret = await db.getSetting('oidc.client_secret');
|
||||||
|
|
@ -123,11 +159,12 @@ router.get('/oidc/callback', async function(req, res) {
|
||||||
var oidc = require('openid-client');
|
var oidc = require('openid-client');
|
||||||
var redirectUri = appUrl + '/api/auth/oidc/callback';
|
var redirectUri = appUrl + '/api/auth/oidc/callback';
|
||||||
|
|
||||||
|
await assertSafeIssuer(issuer);
|
||||||
var config = await oidc.discovery(new URL(issuer), clientId, clientSecret || undefined);
|
var config = await oidc.discovery(new URL(issuer), clientId, clientSecret || undefined);
|
||||||
|
|
||||||
var tokens = await oidc.authorizationCodeGrant(config, new URL(req.protocol + '://' + req.get('host') + req.originalUrl), {
|
var tokens = await oidc.authorizationCodeGrant(config, new URL(req.protocol + '://' + req.get('host') + req.originalUrl), {
|
||||||
pkceCodeVerifier: pending.codeVerifier,
|
pkceCodeVerifier: pending.v,
|
||||||
expectedNonce: pending.nonce,
|
expectedNonce: pending.n,
|
||||||
expectedState: state
|
expectedState: state
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue