diff --git a/README.md b/README.md index 47179a2..5854ce7 100644 --- a/README.md +++ b/README.md @@ -318,31 +318,61 @@ advertising it — put the account on the hidden plan and it simply gets those l above deliberately lists hidden plans too (marked as such), because the previous behaviour was that a hidden plan was invisible to the operator as well as the customer. -#### Google OAuth +#### Single sign-on (OpenID Connect) -Let users sign in with Google. +Any OIDC provider works — Google, Microsoft/Entra, Okta, Auth0, Keycloak, Authentik, Zitadel — through +one flow: **Authorization Code with PKCE, run server-side**. The browser never talks to the provider +directly, so there is no SDK to load and no third-party script origin to allow in the CSP. -1. Create a project in [Google Cloud Console](https://console.cloud.google.com) -2. Enable the Google Identity API -3. Create OAuth 2.0 credentials (web application) -4. Add `https://yourdomain.com` as an authorized origin +Every login is verified as an **ID token**: signature against the provider's published JWKS, +`iss` exactly as discovered, `aud` (and `azp`) matching your client, `exp`, and a `nonce` this server +generated for that specific login. An access token is never accepted as proof of identity. + +Set the redirect URI at your provider to: + +``` +https://yourdomain.com/api/auth/oidc//callback +``` + +Set `APP_URL` so that origin is pinned — the redirect URI must match your provider's registration +exactly, and deriving it from the request `Host` would both break behind a second hostname and take +its value from the caller. + +**Google** and **Microsoft** need only the variables this README has always documented; their issuer +is filled in for you and their slugs are `google` and `microsoft`: | Variable | Description | |----------|-------------| -| `GOOGLE_CLIENT_ID` | Your Google OAuth client ID | +| `GOOGLE_CLIENT_ID` | OAuth 2.0 client ID from [Google Cloud Console](https://console.cloud.google.com) | +| `MICROSOFT_CLIENT_ID` | Application (client) ID from the [Azure portal](https://portal.azure.com) | +| `MICROSOFT_TENANT_ID` | Tenant ID, or `common` for multi-tenant (default `common`) | -#### Microsoft OAuth +⚠️ A tenant GUID narrows the accepted issuer to that tenant, so a token from any other tenant is +rejected. `common` accepts any Microsoft account — which is the point of multi-tenant, but make it a +decision rather than a default you inherited. -Let users sign in with Microsoft/Azure AD. +**Any other provider** is added by slug: -1. Register an app in [Azure Portal](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps) -2. Add a web redirect URI: `https://yourdomain.com` -3. Note the Application (client) ID +```bash +OIDC_PROVIDERS=okta,authentik +OIDC_OKTA_ISSUER=https://example.okta.com +OIDC_OKTA_CLIENT_ID=0oa... +OIDC_OKTA_NAME=Okta # optional button label +OIDC_OKTA_CLIENT_SECRET=... # optional — PKCE means a public client works +OIDC_OKTA_SCOPES=openid email profile # optional +``` -| Variable | Description | -|----------|-------------| -| `MICROSOFT_CLIENT_ID` | Your Azure AD application client ID | -| `MICROSOFT_TENANT_ID` | Tenant ID (`common` for multi-tenant) | +The issuer is the base URL whose `/.well-known/openid-configuration` describes the provider; endpoints +and keys are discovered from it and cached. + +**Account rules.** A provider must assert a verified email, because the whole account model keys on +it. An SSO login never takes over an existing account that has a password — the owner signs in +locally and links from Settings. An account with no password is re-pointed at whichever provider +authenticated it. If the provider's stable subject (`sub`) changes for an address, the login is +refused rather than handing over an account to a recycled mailbox. + +⚠️ **TOTP is not prompted on an SSO login.** Second-factor is the identity provider's job in this +flow, matching the long-standing behaviour of the SSO and API-token paths. #### Email (Microsoft Graph or SMTP) diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index ab0d8f5..251ea07 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -132,6 +132,21 @@ export default { 'auth.trial_notice': 'New accounts get a 14-day free Pro trial', 'auth.divider_or': 'OR', 'auth.signin_google': 'Sign in with Google', + 'auth.signin_with': 'Continue with {provider}', + 'auth.sso_failed': 'Single sign-on failed. Please try again.', + 'auth.sso_err_expired': 'That sign-in took too long. Please try again.', + 'auth.sso_err_bad_state': 'Sign-in could not be verified. Please start again.', + 'auth.sso_err_no_code': 'The provider did not return an authorization code.', + 'auth.sso_err_no_email': 'Your provider did not share an email address.', + 'auth.sso_err_email_unverified': 'Your provider has not verified that email address.', + 'auth.sso_err_verification_failed': 'We could not verify the sign-in with your provider.', + 'auth.sso_err_provider_refused': 'Your provider declined the sign-in.', + 'auth.sso_err_provider_unavailable': 'That provider is not reachable right now.', + 'auth.sso_err_unknown_provider': 'That sign-in provider is not configured.', + 'auth.sso_err_registration_disabled': 'New accounts are disabled on this instance.', + 'auth.sso_err_account_exists_local': 'An account with this email already exists. Sign in with your password, then link your provider in Settings.', + 'auth.sso_err_subject_mismatch': 'This email is already linked to a different account at your provider.', + 'auth.sso_err_server_error': 'Something went wrong completing sign-in.', 'auth.signin_microsoft': 'Sign in with Microsoft', 'auth.back_to_signin': 'Back to Sign In', // TOTP 2FA challenge (second login step) diff --git a/frontend/js/views/login.js b/frontend/js/views/login.js index cdaf376..b912287 100644 --- a/frontend/js/views/login.js +++ b/frontend/js/views/login.js @@ -1,6 +1,33 @@ import { showToast } from '../components/toast.js'; import { t } from '../i18n.js'; + +/* + * A recognisable mark for the providers people expect to see, and an honest generic one for + * everything else. Inline SVG rather than a remote image: an to a provider CDN would put a + * third-party origin back into the CSP, which is precisely what moving the flow server-side removed. + */ +const PROVIDER_ICONS = { + google: ``, + microsoft: ``, +}; + +const GENERIC_ICON = ``; + +const providerIcon = (slug) => PROVIDER_ICONS[slug] || GENERIC_ICON; + let authConfig = null; async function loadAuthConfig() { @@ -148,7 +175,7 @@ export async function render(container) {
- ${config.googleEnabled || config.microsoftEnabled ? ` + ${(config.providers || []).length ? `

${t('auth.divider_or')} @@ -156,31 +183,20 @@ export async function render(container) {
` : ''} - ${config.googleEnabled ? ` -
- -
- ` : ''} - - ${config.microsoftEnabled ? ` - - ` : ''} + + ${(config.providers || []).map((p) => ` + + ${providerIcon(p.slug)} + ${t('auth.signin_with').replace('{provider}', p.name)} + + `).join('')}
@@ -451,66 +467,42 @@ function setupHandlers(config, isSetup) { } } - // Google Sign-In - if (config.googleEnabled) { - document.getElementById('googleSignInBtn')?.addEventListener('click', async () => { - try { - // Use Google's popup-based sign in - const client = google.accounts.oauth2.initTokenClient({ - client_id: config.googleClientId, - scope: 'email profile', - callback: async (response) => { - if (response.access_token) { - // Get ID token via Google's tokeninfo - const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${response.access_token}`); - const tokenData = await tokenRes.json(); - // Send to our server - const res = await fetch('/api/auth/google', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ credential: response.access_token, email: tokenData.email }) - }); - const data = await res.json(); - if (res.ok) onAuthSuccess(data); - else showError(data.error); - } - } - }); - client.requestAccessToken(); - } catch (err) { - showError(t('auth.error_google_failed')); - } - }); + /* + * SSO is a link, not a script. + * + * The buttons above are anchors to /api/auth/oidc//start, so there is nothing to bind here + * and no SDK to wait for. What DOES need handling is the trip back: the callback redirects to + * #/login carrying either a session token or an error code. + * + * The token rides in the URL FRAGMENT, which browsers never send to servers and proxies never + * log — and it is stripped from the address bar before anything else happens, so a shared screen + * or a copied URL does not carry a live session. + */ + const ssoParams = new URLSearchParams((window.location.hash.split('?')[1] || '')); + const ssoToken = ssoParams.get('sso_token'); + const ssoError = ssoParams.get('sso_error'); + + if (ssoToken || ssoError) { + history.replaceState(null, '', window.location.pathname + '#/login'); } - // Microsoft Sign-In - if (config.microsoftEnabled) { - document.getElementById('microsoftSignInBtn')?.addEventListener('click', async () => { - try { - const msalConfig = { - auth: { - clientId: config.microsoftClientId, - authority: `https://login.microsoftonline.com/${config.microsoftTenantId}`, - redirectUri: window.location.origin - } - }; - const msalInstance = new msal.PublicClientApplication(msalConfig); - await msalInstance.initialize(); - const loginResponse = await msalInstance.loginPopup({ scopes: ['User.Read'] }); - if (loginResponse.accessToken) { - const res = await fetch('/api/auth/microsoft', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ access_token: loginResponse.accessToken }) - }); - const data = await res.json(); - if (res.ok) onAuthSuccess(data); - else showError(data.error); - } - } catch (err) { - showError(t('auth.error_microsoft_failed')); - } - }); + if (ssoToken) { + try { + const meRes = await fetch('/api/auth/me', { headers: { Authorization: `Bearer ${ssoToken}` } }); + if (!meRes.ok) throw new Error('session rejected'); + const me = await meRes.json(); + onAuthSuccess({ token: ssoToken, user: me.user || me }); + } catch { + showToast(t('auth.sso_failed'), 'error'); + } + } else if (ssoError) { + // Every code the callback can emit has a message; an unknown one still says something true + // rather than failing silently, which is how the previous implementation behaved on every click. + const known = ['expired', 'bad_state', 'no_code', 'no_email', 'email_unverified', + 'verification_failed', 'provider_refused', 'provider_unavailable', 'unknown_provider', + 'registration_disabled', 'account_exists_local', 'subject_mismatch', 'server_error']; + const key = known.includes(ssoError) ? `auth.sso_err_${ssoError}` : 'auth.sso_failed'; + showToast(t(key), 'error'); } } diff --git a/server/lib/oidc-providers.js b/server/lib/oidc-providers.js new file mode 100644 index 0000000..a6a55c4 --- /dev/null +++ b/server/lib/oidc-providers.js @@ -0,0 +1,119 @@ +'use strict'; + +/* + * Which identity providers this instance offers. + * + * Providers are resolved through ONE function on purpose. Instance-wide providers come from the + * environment today; per-organization SSO will come from the database later, and when it does it + * plugs in here rather than growing a second login path. The rest of the app only ever asks + * "give me the provider called X" and never learns where the answer came from. + * + * ── Configuration ──────────────────────────────────────────────────────────────────────────── + * + * OIDC_PROVIDERS=okta,authentik comma-separated slugs to enable + * OIDC_OKTA_ISSUER=https://example.okta.com + * OIDC_OKTA_CLIENT_ID=... + * OIDC_OKTA_CLIENT_SECRET=... optional — PKCE means a public client works + * OIDC_OKTA_NAME=Okta optional button label + * OIDC_OKTA_SCOPES=openid email profile optional + * + * Google and Microsoft are ordinary OIDC providers and are registered automatically from the + * variables the README has always documented (GOOGLE_CLIENT_ID, MICROSOFT_CLIENT_ID + + * MICROSOFT_TENANT_ID), so an existing deployment keeps working without editing anything. They get + * no special code path — the only difference is that their issuer is filled in for you. + */ + +const GOOGLE_ISSUER = 'https://accounts.google.com'; +const DEFAULT_SCOPES = 'openid email profile'; + +/** A slug has to be safe in a URL path and in an env var name. */ +const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,30}$/; + +function envKey(slug, suffix) { + return `OIDC_${slug.toUpperCase().replace(/-/g, '_')}_${suffix}`; +} + +function fromEnv(env, slug) { + const issuer = (env[envKey(slug, 'ISSUER')] || '').trim().replace(/\/+$/, ''); + const clientId = (env[envKey(slug, 'CLIENT_ID')] || '').trim(); + if (!issuer || !clientId) return null; + return { + slug, + name: (env[envKey(slug, 'NAME')] || '').trim() || slug.replace(/[-_]/g, ' '), + issuer, + clientId, + clientSecret: (env[envKey(slug, 'CLIENT_SECRET')] || '').trim() || null, + scopes: (env[envKey(slug, 'SCOPES')] || '').trim() || DEFAULT_SCOPES, + source: 'env', + }; +} + +/** + * Every provider this instance offers, in a stable order. + * + * ⚠️ Never returns clientSecret to a caller that only wants to draw buttons — see publicList(). + */ +function list(env = process.env) { + const out = []; + const seen = new Set(); + + // Back-compat: the two providers the README documented before generic OIDC existed. + const googleId = (env.GOOGLE_CLIENT_ID || '').trim(); + if (googleId) { + out.push({ + slug: 'google', + name: 'Google', + issuer: GOOGLE_ISSUER, + clientId: googleId, + clientSecret: (env.GOOGLE_CLIENT_SECRET || '').trim() || null, + scopes: DEFAULT_SCOPES, + source: 'env', + }); + seen.add('google'); + } + + const msId = (env.MICROSOFT_CLIENT_ID || '').trim(); + if (msId) { + // `common` lets any Microsoft account in, which is what multi-tenant means and is the documented + // default. A single-tenant deployment sets the tenant GUID and the issuer narrows with it, so a + // token from another tenant then fails the iss check rather than being silently accepted. + const tenant = (env.MICROSOFT_TENANT_ID || 'common').trim() || 'common'; + out.push({ + slug: 'microsoft', + name: 'Microsoft', + issuer: `https://login.microsoftonline.com/${tenant}/v2.0`, + clientId: msId, + clientSecret: (env.MICROSOFT_CLIENT_SECRET || '').trim() || null, + scopes: DEFAULT_SCOPES, + source: 'env', + }); + seen.add('microsoft'); + } + + for (const raw of String(env.OIDC_PROVIDERS || '').split(',')) { + const slug = raw.trim().toLowerCase(); + if (!slug || seen.has(slug)) continue; + if (!SLUG_RE.test(slug)) continue; // ignore rather than crash a boot over a typo + const p = fromEnv(env, slug); + if (p) { out.push(p); seen.add(slug); } + } + + return out; +} + +/** One provider by slug, or null. This is the seam per-org SSO will extend. */ +function get(slug, env = process.env) { + if (!slug || !SLUG_RE.test(String(slug))) return null; + return list(env).find((p) => p.slug === slug) || null; +} + +/** + * What the login page is allowed to know: enough to draw a button and nothing else. + * No client ids, because the browser never talks to the provider directly any more — the redirect + * is built server-side, so there is nothing for the page to do with one. + */ +function publicList(env = process.env) { + return list(env).map((p) => ({ slug: p.slug, name: p.name })); +} + +module.exports = { list, get, publicList, DEFAULT_SCOPES, SLUG_RE }; diff --git a/server/lib/oidc.js b/server/lib/oidc.js new file mode 100644 index 0000000..010fc2a --- /dev/null +++ b/server/lib/oidc.js @@ -0,0 +1,224 @@ +'use strict'; + +/* + * OpenID Connect — discovery, key handling and ID-token verification. + * + * This exists because the previous "OAuth" support verified nothing that mattered. The Google path + * asked Google's tokeninfo endpoint whether an ACCESS token was valid and then trusted the email in + * the reply; the Microsoft path handed a bearer token to Graph /me and trusted that. Neither ever + * checked WHO THE TOKEN WAS ISSUED FOR, and an access token is not a proof of identity — it is a + * bearer credential for some resource, minted for some application, and Graph will happily describe + * the user behind a token issued to somebody else's app. Any site a user signs into that asks for + * `email` or `User.Read` could replay that token here and be issued a session as that user. + * + * So identity now comes from an ID TOKEN and nothing else, and the token has to survive: + * + * signature against the provider's published JWKS, restricted to asymmetric algorithms + * iss exactly the issuer discovery advertised + * aud contains our client_id (and azp === client_id when the token carries one) + * exp/nbf inside a small clock skew + * nonce equal to the one WE generated for this login, which is what stops a token + * obtained elsewhere — even a correctly-audienced one — being replayed here + * + * Deliberately dependency-free beyond `jsonwebtoken`: Node can import a JWK straight into a + * KeyObject, so there is no need for jwks-rsa and no second opinion about what a key is. + */ + +const crypto = require('crypto'); +const jwt = require('jsonwebtoken'); + +/* + * `alg: "none"` is the oldest JWT attack there is, and HMAC is nearly as bad here: an HS256 token is + * verified with a SHARED SECRET, and the only "key" we have for a provider is its PUBLIC one — which + * an attacker also has, and could sign with. Only asymmetric families are ever acceptable. + */ +const ALLOWED_ALGS = ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'PS256', 'PS384', 'PS512']; + +// Providers rotate keys and publish new ones ahead of use, so a short cache is safe and a miss is +// cheap. Discovery changes far less often but is cached the same way for one reason: a provider +// outage should not be able to stall every login for as long as it lasts. +const DISCOVERY_TTL_MS = 60 * 60 * 1000; // 1 hour +const JWKS_TTL_MS = 10 * 60 * 1000; // 10 minutes +const FETCH_TIMEOUT_MS = 8000; + +const discoveryCache = new Map(); // issuer -> { at, doc } +const jwksCache = new Map(); // jwks_uri -> { at, keys } + +/** fetch with a timeout, because a hanging IdP must not hang a login forever. */ +async function getJson(url) { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { signal: ctl.signal, redirect: 'follow' }); + if (!res.ok) throw new Error(`${url} responded ${res.status}`); + return await res.json(); + } finally { + clearTimeout(timer); + } +} + +/** + * The provider's own description of itself. + * + * ⚠️ The discovered `issuer` is checked against the configured one. Discovery is fetched over TLS + * from a URL derived from the issuer, so this is belt-and-braces — but a provider whose document + * claims a DIFFERENT issuer is either misconfigured or hostile, and either way its tokens must not + * be accepted under a name it does not own. + */ +async function discover(issuer) { + const key = String(issuer).replace(/\/+$/, ''); + const hit = discoveryCache.get(key); + if (hit && Date.now() - hit.at < DISCOVERY_TTL_MS) return hit.doc; + + const url = `${key}/.well-known/openid-configuration`; + const doc = await getJson(url); + + const advertised = String(doc.issuer || '').replace(/\/+$/, ''); + if (advertised !== key) { + throw new Error(`discovery issuer mismatch: configured ${key}, document says ${doc.issuer}`); + } + for (const required of ['authorization_endpoint', 'token_endpoint', 'jwks_uri']) { + if (!doc[required]) throw new Error(`discovery for ${key} is missing ${required}`); + } + + discoveryCache.set(key, { at: Date.now(), doc }); + return doc; +} + +/** + * The signing key for one token. + * + * An unknown `kid` forces ONE refresh: that is the normal shape of a key rotation, and refusing to + * refetch would fail every login until the cache expired. It is bounded to one refresh per call so + * a token quoting nonsense cannot be used to hammer the provider. + */ +async function keyForKid(jwksUri, kid) { + let entry = jwksCache.get(jwksUri); + const fresh = entry && Date.now() - entry.at < JWKS_TTL_MS; + + if (!fresh || !entry.keys.some((k) => k.kid === kid)) { + const doc = await getJson(jwksUri); + entry = { at: Date.now(), keys: Array.isArray(doc.keys) ? doc.keys : [] }; + jwksCache.set(jwksUri, entry); + } + + const jwk = entry.keys.find((k) => k.kid === kid) + // A provider with exactly one key may omit kid entirely; anything ambiguous is refused rather + // than guessed, because "try each key until one verifies" is how you accept a key you did not mean to. + || (!kid && entry.keys.length === 1 ? entry.keys[0] : null); + if (!jwk) throw new Error(`no signing key for kid ${kid || '(none)'}`); + + return crypto.createPublicKey({ key: jwk, format: 'jwk' }); +} + +/** + * Verify an ID token and return its claims. + * + * `nonce` is REQUIRED by this function even though the spec makes it conditional. Every flow here + * is a browser login we initiated, so we always have one to compare — and it is the single check + * that distinguishes "a token minted for us, now" from "a token minted for us at some point, + * captured, and replayed". + */ +async function verifyIdToken(idToken, { issuer, clientId, nonce }) { + if (!idToken || typeof idToken !== 'string') throw new Error('no id_token'); + if (!nonce) throw new Error('no nonce to verify against'); + + const decoded = jwt.decode(idToken, { complete: true }); + if (!decoded || !decoded.header) throw new Error('id_token is not a JWT'); + if (!ALLOWED_ALGS.includes(decoded.header.alg)) { + throw new Error(`refusing id_token algorithm ${decoded.header.alg}`); + } + + const doc = await discover(issuer); + const key = await keyForKid(doc.jwks_uri, decoded.header.kid); + + // jsonwebtoken checks signature, exp, nbf, iss and aud. The algorithm allowlist is passed + // explicitly so the header cannot choose how it is verified. + const claims = jwt.verify(idToken, key, { + algorithms: ALLOWED_ALGS, + issuer: doc.issuer, + audience: clientId, + clockTolerance: 60, + }); + + if (claims.nonce !== nonce) throw new Error('id_token nonce does not match this login'); + + /* + * azp names the party the token was issued TO when it differs from the audience. If it is present + * it must be us: a token with our client_id merely in a multi-valued `aud`, issued to a different + * application, is exactly the confused-deputy case this whole file exists to prevent. + */ + if (claims.azp && claims.azp !== clientId) { + throw new Error('id_token was issued to a different application'); + } + if (!claims.sub) throw new Error('id_token has no subject'); + + return claims; +} + +/** PKCE S256. The verifier never leaves us; only its hash goes to the provider. */ +function createPkce() { + const verifier = crypto.randomBytes(32).toString('base64url'); + const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); + return { verifier, challenge, method: 'S256' }; +} + +const randomToken = () => crypto.randomBytes(32).toString('base64url'); + +/** + * Exchange the authorization code. + * + * PKCE means a public client needs no secret, which is what lets a self-hoster configure a provider + * without one. A secret is still sent when configured, because some providers (and some admins) + * require confidential clients. + */ +async function exchangeCode({ issuer, clientId, clientSecret, code, redirectUri, verifier }) { + const doc = await discover(issuer); + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: verifier, + }); + + const headers = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }; + if (clientSecret) { + // client_secret_basic is the form every provider accepts; client_secret_post is not universal. + headers.Authorization = 'Basic ' + Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`).toString('base64'); + } + + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS); + let payload; + try { + const res = await fetch(doc.token_endpoint, { method: 'POST', headers, body, signal: ctl.signal }); + payload = await res.json().catch(() => ({})); + if (!res.ok) { + // The provider's own error is far more useful than "exchange failed" — a wrong redirect_uri + // or an unregistered client is the overwhelmingly common setup mistake and it says so here. + throw new Error(payload.error_description || payload.error || `token endpoint responded ${res.status}`); + } + } finally { + clearTimeout(timer); + } + + if (!payload.id_token) throw new Error('provider returned no id_token — is the openid scope requested?'); + return payload; +} + +/** Test seam: drop cached discovery/JWKS so a test can change what a provider claims. */ +function _resetCaches() { + discoveryCache.clear(); + jwksCache.clear(); +} + +module.exports = { + discover, + verifyIdToken, + exchangeCode, + createPkce, + randomToken, + ALLOWED_ALGS, + _resetCaches, +}; diff --git a/server/routes/auth.js b/server/routes/auth.js index 49b1b45..98db406 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -3,7 +3,6 @@ const router = express.Router(); const bcrypt = require('bcryptjs'); const https = require('https'); const { v4: uuidv4 } = require('uuid'); -const { OAuth2Client } = require('google-auth-library'); const { db } = require('../db/database'); const { generateToken, generateMfaPendingToken, verifyMfaPendingToken, requireAuth, requireAdmin, requireSuperAdmin, isPlatformRole, isPlatformStaff, PLATFORM_ROLES } = require('../middleware/auth'); const { resolveTenancy } = require('../lib/tenancy'); @@ -18,6 +17,10 @@ const emailVerify = require('../lib/emailVerify'); const emailSvc = require('../services/email'); const { deleteUserCascade, OrgHasOtherMembersError } = require('../lib/user-deletion'); const config = require('../config'); +const crypto = require('crypto'); +const jwt = require('jsonwebtoken'); +const oidc = require('../lib/oidc'); +const oidcProviders = require('../lib/oidc-providers'); // Phase 2.1: find or create the user's default org+workspace. Returns the // workspace_id to embed in the JWT. Idempotent: if the user already has @@ -456,160 +459,24 @@ router.post('/totp/verify', (req, res) => { // ==================== Google OAuth ==================== -router.post('/google', async (req, res) => { - const { credential } = req.body; - if (!credential) return res.status(400).json({ error: 'Google credential required' }); +/* + * REMOVED 2026-08-10: POST /api/auth/google and POST /api/auth/microsoft. + * + * Both authenticated with an ACCESS token and neither checked who it was issued for. Google's path + * fell back to `tokeninfo?access_token=` and read the email out of the reply; Microsoft's handed the + * bearer token to Graph /me and trusted that. Graph — and tokeninfo — will describe the user behind + * a token minted for SOMEBODY ELSE'S application, so any site a user signed into that requested + * `email` or `User.Read` could replay their token here and be handed a session as them. + * + * Nothing is lost by deleting them: the login page called `google.accounts.oauth2` and + * `new msal.PublicClientApplication`, and neither SDK was ever loaded by any page in this app, so + * both buttons threw ReferenceError on click. The feature had never worked. + * + * Replaced by the OIDC routes at the bottom of this file, which verify an ID token's signature, + * issuer, audience and our own nonce, and which cover Google, Microsoft and any other provider + * through one code path. See lib/oidc.js. + */ - try { - // Verify the Google ID token - const payload = await verifyGoogleToken(credential); - if (!payload) return res.status(401).json({ error: 'Invalid Google token' }); - - const { email, name, picture, sub: googleId } = payload; - - // Find or create user - let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase()); - const isNewUser = !user; - - if (!user) { - if (!canRegister()) { - return res.status(403).json({ error: 'Public registration is disabled. Contact your administrator.' }); - } - const id = uuidv4(); - const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; - const role = userCount === 0 ? 'platform_admin' : 'user'; - const isFirst = userCount === 0; - const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro'; - const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000); - - db.prepare(` - INSERT INTO users (id, email, name, auth_provider, provider_id, avatar_url, role, plan_id, trial_started, trial_plan, email_verified) - VALUES (?, ?, ?, 'google', ?, ?, ?, ?, ?, ?, 1) - `).run(id, email.toLowerCase(), name || '', googleId, picture || '', role, plan, trialStarted, trialStarted ? 'pro' : null); - - user = db.prepare('SELECT * FROM users WHERE id = ?').get(id); - } else if (user.auth_provider !== 'google') { - // Existing account with different provider — do NOT silently overwrite auth_provider. - // If they have a local password, require them to log in locally and link from settings. - if (user.password_hash) { - return res.status(409).json({ error: 'An account with this email already exists. Please log in with your password.' }); - } - // No password (e.g. Microsoft → Google switch) — allow linking - db.prepare('UPDATE users SET auth_provider = ?, provider_id = ?, avatar_url = ? WHERE id = ?') - .run('google', googleId, picture || user.avatar_url, user.id); - user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id); - } - - const workspaceId = ensureDefaultOrgForUser(user, { allowCreate: config.autoCreateOrgOnSignup }); - const token = generateToken(user, workspaceId); - const { password_hash, ...safeUser } = user; - res.json({ token, user: safeUser, current_workspace_id: workspaceId }); - - // Welcome + admin-notify only when this Google login created a new account. - if (isNewUser) sendSignupEmails(user, req); - } catch (err) { - console.error('Google auth error:', err); - res.status(401).json({ error: 'Google authentication failed' }); - } -}); - -async function verifyGoogleToken(credential) { - const client = new OAuth2Client(config.googleClientId); - try { - const ticket = await client.verifyIdToken({ - idToken: credential, - audience: config.googleClientId || undefined, - }); - return ticket.getPayload(); - } catch (e) { - // Fallback: if credential is an access token, verify via tokeninfo - try { - const res = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${credential}`); - if (!res.ok) throw new Error('Invalid token'); - return await res.json(); - } catch { - throw new Error('Google token verification failed: ' + e.message); - } - } -} - -// ==================== Microsoft OAuth ==================== - -router.post('/microsoft', async (req, res) => { - const { access_token } = req.body; - if (!access_token) return res.status(400).json({ error: 'Microsoft access token required' }); - - try { - // Use the access token to get user profile from Microsoft Graph - const profile = await getMicrosoftProfile(access_token); - if (!profile || !profile.mail && !profile.userPrincipalName) { - return res.status(401).json({ error: 'Could not get Microsoft profile' }); - } - - const email = (profile.mail || profile.userPrincipalName).toLowerCase(); - const name = profile.displayName || ''; - const microsoftId = profile.id; - - // Find or create user - let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email); - const isNewUser = !user; - - if (!user) { - if (!canRegister()) { - return res.status(403).json({ error: 'Public registration is disabled. Contact your administrator.' }); - } - const id = uuidv4(); - const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; - const role = userCount === 0 ? 'platform_admin' : 'user'; - const isFirst = userCount === 0; - const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro'; - const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000); - - db.prepare(` - INSERT INTO users (id, email, name, auth_provider, provider_id, role, plan_id, trial_started, trial_plan, email_verified) - VALUES (?, ?, ?, 'microsoft', ?, ?, ?, ?, ?, 1) - `).run(id, email, name, microsoftId, role, plan, trialStarted, trialStarted ? 'pro' : null); - - user = db.prepare('SELECT * FROM users WHERE id = ?').get(id); - } else if (user.auth_provider !== 'microsoft') { - // Existing account with different provider — do NOT silently overwrite auth_provider. - if (user.password_hash) { - return res.status(409).json({ error: 'An account with this email already exists. Please log in with your password.' }); - } - db.prepare('UPDATE users SET auth_provider = ?, provider_id = ? WHERE id = ?') - .run('microsoft', microsoftId, user.id); - user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id); - } - - const workspaceId = ensureDefaultOrgForUser(user, { allowCreate: config.autoCreateOrgOnSignup }); - const token = generateToken(user, workspaceId); - const { password_hash, ...safeUser } = user; - res.json({ token, user: safeUser, current_workspace_id: workspaceId }); - - // Welcome + admin-notify only when this Microsoft login created a new account. - if (isNewUser) sendSignupEmails(user, req); - } catch (err) { - console.error('Microsoft auth error:', err); - res.status(401).json({ error: 'Microsoft authentication failed' }); - } -}); - -function getMicrosoftProfile(accessToken) { - return new Promise((resolve, reject) => { - const options = { - hostname: 'graph.microsoft.com', - path: '/v1.0/me', - headers: { Authorization: `Bearer ${accessToken}` } - }; - https.get(options, (resp) => { - let data = ''; - resp.on('data', chunk => data += chunk); - resp.on('end', () => { - try { resolve(JSON.parse(data)); } catch (e) { reject(e); } - }); - }).on('error', reject); - }); -} // ==================== User Management ==================== @@ -876,12 +743,19 @@ router.put('/users/:id/password', requireAuth, requireAdmin, (req, res) => { // Get auth config (public - tells frontend which providers are available) router.get('/config', (req, res) => { const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; + /* + * `providers` is the whole SSO surface now: slug + display name, nothing else. The browser no + * longer needs a client id, because it never talks to a provider itself — it follows a link to + * /api/auth/oidc//start and the server builds the authorization request. That is what + * removed the need for a provider SDK on this page, and with it the CSP exception one would need. + */ + const providers = oidcProviders.publicList(); res.json({ - googleEnabled: !!config.googleClientId, - googleClientId: config.googleClientId, - microsoftEnabled: !!config.microsoftClientId, - microsoftClientId: config.microsoftClientId, - microsoftTenantId: config.microsoftTenantId, + providers, + // Kept so a cached older login page hides its buttons rather than drawing dead ones. The client + // ids are deliberately no longer echoed — nothing in the browser has any use for them. + googleEnabled: providers.some((p) => p.slug === 'google'), + microsoftEnabled: providers.some((p) => p.slug === 'microsoft'), localEnabled: true, needsSetup: userCount === 0, registration_enabled: !config.disableRegistration || userCount === 0, @@ -948,4 +822,229 @@ router.post('/accept-invite/:inviteId', requireAuth, (req, res) => { }); }); + +// ==================== OpenID Connect (generic SSO) ==================== +/* + * ONE flow for every provider — Google, Microsoft, Okta, Keycloak, Authentik, anything that speaks + * OIDC. Authorization Code + PKCE, run server-side, which is why there is no provider SDK on the + * login page and no third-party script origin in the CSP. + * + * It replaces two endpoints that could not tell WHO a token was minted for. Detail in lib/oidc.js; + * the short version is that identity now comes from an ID token whose signature, issuer, audience + * and OUR nonce are all checked, instead of from an access token handed to a userinfo endpoint. + * + * ⚠️ TOTP: an SSO login does not prompt for it, matching the existing documented behaviour at the + * password-login branch above ("The SSO routes and the API-token path never reach here"). The + * second factor is the identity provider's job in this flow. Changing that is a product decision, + * not something this refactor should do silently. + */ + +// The transaction is held in a short-lived signed cookie rather than server memory so that a +// restart mid-login, or a second server process, does not strand the user on a dead state. +const OIDC_TX_COOKIE = 'st_oidc_tx'; +const OIDC_TX_TTL_S = 600; + +function readCookie(req, name) { + const raw = req.headers.cookie; + if (!raw) return null; + for (const part of raw.split(';')) { + const eq = part.indexOf('='); + if (eq === -1) continue; + if (part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim()); + } + return null; +} + +/* + * The origin the provider will redirect back to. APP_URL pins it, exactly as the signup and invite + * mails do, because the redirect_uri must match what is registered with the provider CHARACTER FOR + * CHARACTER — deriving it from the request Host would break the moment someone reaches the box by + * a second name, and would be attacker-controlled input in the bargain. + */ +function publicOrigin(req) { + const configured = (process.env.APP_URL || '').trim().replace(/\/+$/, ''); + if (configured) return configured; + return `${req.protocol}://${req.get('host')}`; +} + +const redirectUriFor = (req, slug) => `${publicOrigin(req)}/api/auth/oidc/${slug}/callback`; + +// Send the browser back to the SPA. Errors travel as a code the login page can translate; the +// token travels in the FRAGMENT, which browsers do not send to servers and proxies do not log. +function backToApp(res, params) { + const qs = new URLSearchParams(params).toString(); + res.redirect(`/app#/login?${qs}`); +} + +// Which providers this instance offers. Public: it is what draws the login buttons. +router.get('/providers', (req, res) => { + res.json({ providers: oidcProviders.publicList() }); +}); + +router.get('/oidc/:slug/start', async (req, res) => { + const provider = oidcProviders.get(req.params.slug); + if (!provider) return backToApp(res, { sso_error: 'unknown_provider' }); + + try { + const doc = await oidc.discover(provider.issuer); + const pkce = oidc.createPkce(); + const nonce = oidc.randomToken(); + const state = oidc.randomToken(); + + const tx = jwt.sign( + { slug: provider.slug, nonce, verifier: pkce.verifier, state }, + config.jwtSecret, + { expiresIn: OIDC_TX_TTL_S }, + ); + res.cookie(OIDC_TX_COOKIE, tx, { + httpOnly: true, + sameSite: 'lax', // the provider returns via a top-level GET, which Lax allows + secure: req.protocol === 'https', + maxAge: OIDC_TX_TTL_S * 1000, + path: '/api/auth', + }); + + const url = new URL(doc.authorization_endpoint); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', provider.clientId); + url.searchParams.set('redirect_uri', redirectUriFor(req, provider.slug)); + url.searchParams.set('scope', provider.scopes); + url.searchParams.set('state', state); + url.searchParams.set('nonce', nonce); + url.searchParams.set('code_challenge', pkce.challenge); + url.searchParams.set('code_challenge_method', pkce.method); + res.redirect(url.toString()); + } catch (err) { + console.error(`[oidc] ${req.params.slug} start failed:`, err.message); + backToApp(res, { sso_error: 'provider_unavailable' }); + } +}); + +router.get('/oidc/:slug/callback', async (req, res) => { + const provider = oidcProviders.get(req.params.slug); + if (!provider) return backToApp(res, { sso_error: 'unknown_provider' }); + + // The provider itself can refuse (consent declined, admin policy). That is not an error here. + if (req.query.error) { + console.warn(`[oidc] ${provider.slug} returned ${req.query.error}`); + return backToApp(res, { sso_error: 'provider_refused' }); + } + + const raw = readCookie(req, OIDC_TX_COOKIE); + res.clearCookie(OIDC_TX_COOKIE, { path: '/api/auth' }); + if (!raw) return backToApp(res, { sso_error: 'expired' }); + + let tx; + try { + tx = jwt.verify(raw, config.jwtSecret); + } catch { + return backToApp(res, { sso_error: 'expired' }); + } + + // CSRF: the state we minted, in the cookie only we could set, must match the one coming back. + // Compared in constant time so a wrong state cannot be discovered a character at a time. + const got = String(req.query.state || ''); + const want = String(tx.state || ''); + const sameLength = got.length === want.length; + if (!sameLength || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))) { + return backToApp(res, { sso_error: 'bad_state' }); + } + if (tx.slug !== provider.slug) return backToApp(res, { sso_error: 'bad_state' }); + if (!req.query.code) return backToApp(res, { sso_error: 'no_code' }); + + let claims; + try { + const tokens = await oidc.exchangeCode({ + issuer: provider.issuer, + clientId: provider.clientId, + clientSecret: provider.clientSecret, + code: String(req.query.code), + redirectUri: redirectUriFor(req, provider.slug), + verifier: tx.verifier, + }); + claims = await oidc.verifyIdToken(tokens.id_token, { + issuer: provider.issuer, + clientId: provider.clientId, + nonce: tx.nonce, + }); + } catch (err) { + console.error(`[oidc] ${provider.slug} verification failed:`, err.message); + return backToApp(res, { sso_error: 'verification_failed' }); + } + + const email = String(claims.email || '').toLowerCase().trim(); + if (!email) return backToApp(res, { sso_error: 'no_email' }); + /* + * An unverified email is refused. The whole account model keys on email — linking, invites, + * password reset — so accepting an address the provider itself will not vouch for would let + * anyone who can type an address into a sloppy IdP arrive as its owner. Providers that omit the + * claim entirely are treated as "not asserted", which is the same answer. + */ + if (claims.email_verified === false) return backToApp(res, { sso_error: 'email_unverified' }); + + try { + const result = upsertFederatedUser({ claims, email, provider, req }); + if (result.error) return backToApp(res, { sso_error: result.error }); + const { user, isNew } = result; + + logSuccessfulLogin(user.id, user.email, getClientIp(req)); + const workspaceId = ensureDefaultOrgForUser(user, { allowCreate: config.autoCreateOrgOnSignup }); + const token = generateToken(user, workspaceId); + if (isNew) sendSignupEmails(user, req); + backToApp(res, { sso_token: token }); + } catch (err) { + console.error(`[oidc] ${provider.slug} sign-in failed:`, err.message); + backToApp(res, { sso_error: 'server_error' }); + } +}); + +/* + * Find or create the account behind a verified set of claims. + * + * The linking rule is the one the Google path already used, kept deliberately: an existing account + * WITH a password is never taken over by an SSO login — the owner proves control by logging in + * locally and linking from Settings. An account with no password (already federated) is re-pointed + * at whichever provider just authenticated it. + */ +function upsertFederatedUser({ claims, email, provider, req }) { + const existing = db.prepare('SELECT * FROM users WHERE email = ?').get(email); + + if (!existing) { + if (!canRegister()) return { error: 'registration_disabled' }; + const id = uuidv4(); + const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; + const isFirst = userCount === 0; + const role = isFirst ? 'platform_admin' : 'user'; + const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro'; + const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000); + db.prepare(` + INSERT INTO users (id, email, name, auth_provider, provider_id, avatar_url, role, plan_id, trial_started, trial_plan, email_verified) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + `).run(id, email, claims.name || '', provider.slug, String(claims.sub), claims.picture || '', + role, plan, trialStarted, trialStarted ? 'pro' : null); + return { user: db.prepare('SELECT * FROM users WHERE id = ?').get(id), isNew: true }; + } + + if (existing.auth_provider !== provider.slug) { + if (existing.password_hash) return { error: 'account_exists_local' }; + db.prepare('UPDATE users SET auth_provider = ?, provider_id = ?, avatar_url = ? WHERE id = ?') + .run(provider.slug, String(claims.sub), claims.picture || existing.avatar_url, existing.id); + return { user: db.prepare('SELECT * FROM users WHERE id = ?').get(existing.id), isNew: false }; + } + + /* + * Same provider, but a DIFFERENT subject. `sub` is the provider's stable id and the email is not: + * addresses get reassigned, especially inside companies. Refusing here is what stops a recycled + * address inheriting the previous holder's account. + */ + if (existing.provider_id && String(existing.provider_id) !== String(claims.sub)) { + return { error: 'subject_mismatch' }; + } + if (!existing.provider_id) { + db.prepare('UPDATE users SET provider_id = ? WHERE id = ?').run(String(claims.sub), existing.id); + } + return { user: db.prepare('SELECT * FROM users WHERE id = ?').get(existing.id), isNew: false }; +} + + module.exports = router; diff --git a/server/test/oidc-sso.test.js b/server/test/oidc-sso.test.js new file mode 100644 index 0000000..617e1d4 --- /dev/null +++ b/server/test/oidc-sso.test.js @@ -0,0 +1,262 @@ +'use strict'; + +/* + * The SSO that shipped before this verified nothing that mattered, and had no tests at all. + * + * Google's path asked `tokeninfo?access_token=` whether a token was valid and trusted the email in + * the answer; Microsoft's handed a bearer token to Graph /me and trusted that. Neither asked WHO + * THE TOKEN WAS ISSUED FOR. An access token is a bearer credential for a resource, minted for some + * application — so any site a user signed into that requested `email` or `User.Read` could replay + * their token and be handed a session as them. + * + * These tests exist so that cannot come back. Every one of them describes an attack that the old + * code would have waved through, and they run against a REAL RSA keypair and a REAL JWKS document + * so the verifier is exercised the way a provider would exercise it — not against a stub that + * agrees with us. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const jwt = require('jsonwebtoken'); + +const oidc = require('../lib/oidc'); +const providers = require('../lib/oidc-providers'); + +// --------------------------------------------------------------------------------------------- +// A pretend identity provider: one keypair, one JWKS, one discovery document. + +const ISSUER = 'https://idp.example.com'; +const CLIENT_ID = 'screentinker-test-client'; +const KID = 'test-key-1'; + +const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); +const JWKS = { keys: [{ ...publicKey.export({ format: 'jwk' }), kid: KID, use: 'sig', alg: 'RS256' }] }; + +// A second keypair nobody should trust — the "signed by someone else" case. +const rogue = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + +function discoveryDoc(issuer = ISSUER) { + return { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + jwks_uri: `${issuer}/jwks`, + }; +} + +/** Point global fetch at the pretend provider. Returns a restore function. */ +function mockProvider({ doc = discoveryDoc(), jwks = JWKS } = {}) { + const real = global.fetch; + global.fetch = async (url) => { + const u = String(url); + if (u.endsWith('/.well-known/openid-configuration')) { + return { ok: true, status: 200, json: async () => doc }; + } + if (u.endsWith('/jwks')) { + return { ok: true, status: 200, json: async () => jwks }; + } + return { ok: false, status: 404, json: async () => ({}) }; + }; + oidc._resetCaches(); + return () => { global.fetch = real; oidc._resetCaches(); }; +} + +const idToken = (claims = {}, { key = privateKey, alg = 'RS256', kid = KID } = {}) => jwt.sign( + { iss: ISSUER, aud: CLIENT_ID, sub: 'user-123', email: 'a@example.com', nonce: 'NONCE', ...claims }, + key, { algorithm: alg, keyid: kid, expiresIn: '5m' }, +); + +const verify = (token, over = {}) => + oidc.verifyIdToken(token, { issuer: ISSUER, clientId: CLIENT_ID, nonce: 'NONCE', ...over }); + +// --------------------------------------------------------------------------------------------- + +test('a well-formed token from the right provider verifies', async () => { + const restore = mockProvider(); + try { + const claims = await verify(idToken()); + assert.equal(claims.sub, 'user-123'); + assert.equal(claims.email, 'a@example.com'); + } finally { restore(); } +}); + +test('THE OLD BUG: a token minted for a DIFFERENT application is refused', async () => { + // This is the whole reason the previous implementation was unsafe. Same provider, same user, + // real signature — but issued to somebody else's client. It must not buy a session here. + const restore = mockProvider(); + try { + await assert.rejects(() => verify(idToken({ aud: 'someone-elses-client' })), /audience/i); + } finally { restore(); } +}); + +test('...and neither is one that merely LISTS us alongside its real audience', async () => { + // aud can be an array. azp names who it was actually issued to, and if that is not us then we + // are a bystander in someone else's token — the confused-deputy case. + const restore = mockProvider(); + try { + await assert.rejects( + () => verify(idToken({ aud: [CLIENT_ID, 'other'], azp: 'other' })), + /issued to a different application/i, + ); + } finally { restore(); } +}); + +test('a token captured from an earlier login cannot be replayed', async () => { + // The nonce is minted per login and kept in a signed cookie. Without this check a correctly + // audienced token, obtained any way at all, would be reusable forever. + const restore = mockProvider(); + try { + await assert.rejects(() => verify(idToken({ nonce: 'A-DIFFERENT-LOGIN' })), /nonce/i); + } finally { restore(); } +}); + +test('alg:none is refused', async () => { + const restore = mockProvider(); + try { + // Hand-built, because jsonwebtoken will not sign 'none' for you. + const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT', kid: KID })).toString('base64url'); + const body = Buffer.from(JSON.stringify({ + iss: ISSUER, aud: CLIENT_ID, sub: 'x', email: 'a@example.com', nonce: 'NONCE', + exp: Math.floor(Date.now() / 1000) + 300, + })).toString('base64url'); + await assert.rejects(() => verify(`${header}.${body}.`), /algorithm/i); + } finally { restore(); } +}); + +test('an HMAC-signed token is refused even though the "key" is public', async () => { + // HS256 verifies with a shared secret. The only key we hold for a provider is its PUBLIC one, + // which the attacker also has — so accepting HMAC would let anyone sign their own identity. + const restore = mockProvider(); + try { + const forged = jwt.sign( + { iss: ISSUER, aud: CLIENT_ID, sub: 'x', email: 'admin@example.com', nonce: 'NONCE' }, + publicKey.export({ type: 'spki', format: 'pem' }), + { algorithm: 'HS256', keyid: KID, expiresIn: '5m' }, + ); + await assert.rejects(() => verify(forged), /algorithm/i); + } finally { restore(); } +}); + +test('a token signed by the wrong key is refused', async () => { + const restore = mockProvider(); + try { + await assert.rejects(() => verify(idToken({}, { key: rogue.privateKey })), /signature/i); + } finally { restore(); } +}); + +test('an expired token is refused', async () => { + const restore = mockProvider(); + try { + const stale = jwt.sign( + { iss: ISSUER, aud: CLIENT_ID, sub: 'x', email: 'a@example.com', nonce: 'NONCE', + exp: Math.floor(Date.now() / 1000) - 3600 }, + privateKey, { algorithm: 'RS256', keyid: KID }, + ); + await assert.rejects(() => verify(stale), /expired/i); + } finally { restore(); } +}); + +test('a provider whose discovery claims a different issuer is refused', async () => { + // Discovery is fetched from a URL derived from the configured issuer, so a document naming a + // DIFFERENT one is either broken or hostile. Either way its tokens must not be accepted under a + // name it does not own. + const restore = mockProvider({ doc: discoveryDoc('https://evil.example.com') }); + try { + await assert.rejects(() => verify(idToken()), /issuer mismatch/i); + } finally { restore(); } +}); + +test('verification cannot be skipped by omitting the nonce', async () => { + // Belt and braces: the caller must always have a nonce to compare, so a coding mistake that + // forgets to pass one fails closed rather than accepting anything. + const restore = mockProvider(); + try { + await assert.rejects(() => verify(idToken(), { nonce: undefined }), /nonce/i); + } finally { restore(); } +}); + +test('an unknown kid triggers exactly one JWKS refresh, then gives up', async () => { + // Key rotation is normal and must not fail every login until a cache expires; a token quoting + // nonsense must not become a way to hammer the provider either. + let jwksFetches = 0; + const real = global.fetch; + global.fetch = async (url) => { + const u = String(url); + if (u.endsWith('/.well-known/openid-configuration')) return { ok: true, status: 200, json: async () => discoveryDoc() }; + if (u.endsWith('/jwks')) { jwksFetches++; return { ok: true, status: 200, json: async () => JWKS }; } + return { ok: false, status: 404, json: async () => ({}) }; + }; + oidc._resetCaches(); + try { + await assert.rejects(() => verify(idToken({}, { kid: 'no-such-kid' })), /no signing key/i); + assert.equal(jwksFetches, 1, 'one refresh, not a loop'); + } finally { global.fetch = real; oidc._resetCaches(); } +}); + +// --------------------------------------------------------------------------------------------- +// PKCE + +test('PKCE uses S256 and never sends the verifier', () => { + const { verifier, challenge, method } = oidc.createPkce(); + assert.equal(method, 'S256'); + assert.notEqual(verifier, challenge, 'a plain challenge would make PKCE pointless'); + const expected = crypto.createHash('sha256').update(verifier).digest('base64url'); + assert.equal(challenge, expected); + assert.ok(verifier.length >= 43, 'RFC 7636 wants at least 43 characters of entropy'); +}); + +test('every login gets fresh values', () => { + const a = oidc.createPkce(); const b = oidc.createPkce(); + assert.notEqual(a.verifier, b.verifier); + assert.notEqual(oidc.randomToken(), oidc.randomToken()); +}); + +// --------------------------------------------------------------------------------------------- +// The provider registry + +test('Google and Microsoft register from the variables the README always documented', () => { + const list = providers.list({ GOOGLE_CLIENT_ID: 'g', MICROSOFT_CLIENT_ID: 'm', MICROSOFT_TENANT_ID: 'common' }); + const byslug = Object.fromEntries(list.map((p) => [p.slug, p])); + assert.equal(byslug.google.issuer, 'https://accounts.google.com'); + assert.equal(byslug.microsoft.issuer, 'https://login.microsoftonline.com/common/v2.0'); +}); + +test('a single-tenant Microsoft app narrows the issuer, so another tenant fails iss', () => { + const [ms] = providers.list({ MICROSOFT_CLIENT_ID: 'm', MICROSOFT_TENANT_ID: 'abc-123' }); + assert.equal(ms.issuer, 'https://login.microsoftonline.com/abc-123/v2.0'); +}); + +test('any OIDC provider can be added by env', () => { + const list = providers.list({ + OIDC_PROVIDERS: 'authentik', + OIDC_AUTHENTIK_ISSUER: 'https://id.example.com/application/o/st/', + OIDC_AUTHENTIK_CLIENT_ID: 'abc', + OIDC_AUTHENTIK_NAME: 'Company SSO', + }); + assert.equal(list.length, 1); + assert.equal(list[0].slug, 'authentik'); + assert.equal(list[0].name, 'Company SSO'); + assert.equal(list[0].issuer, 'https://id.example.com/application/o/st', 'trailing slash normalised'); + assert.equal(list[0].clientSecret, null, 'PKCE means a public client is fine'); +}); + +test('an incomplete or malformed provider is ignored rather than crashing boot', () => { + assert.equal(providers.list({ OIDC_PROVIDERS: 'broken' }).length, 0, 'no issuer/client id'); + assert.equal(providers.list({ + OIDC_PROVIDERS: '../etc/passwd', + OIDC_ISSUER: 'https://x', OIDC_CLIENT_ID: 'y', + }).length, 0, 'a slug that is not URL-safe never becomes a route'); +}); + +test('the browser is told slugs and names only — never a client id or secret', () => { + const pub = providers.publicList({ + GOOGLE_CLIENT_ID: 'super-secret-id', + OIDC_PROVIDERS: 'okta', OIDC_OKTA_ISSUER: 'https://x.okta.com', + OIDC_OKTA_CLIENT_ID: 'id', OIDC_OKTA_CLIENT_SECRET: 'shh', + }); + const serialised = JSON.stringify(pub); + assert.ok(!serialised.includes('super-secret-id')); + assert.ok(!serialised.includes('shh')); + assert.deepEqual(Object.keys(pub[0]).sort(), ['name', 'slug']); +});