screentinker/server/lib/oidc-providers.js
ScreenTinker 252854d31e SSO: one OIDC flow for every provider, and verify the token properly
The OAuth support that was here could not work and would not have been safe if
it had.

It could not work: the login page called google.accounts.oauth2 and
new msal.PublicClientApplication, and NEITHER SDK WAS EVER LOADED by any page
in this app — no script tag, no dynamic import, nothing. Both buttons threw
ReferenceError on click. Even had they loaded, the CSP allows scripts only from
'self' and cloudflareinsights, and frames only from self and YouTube, so the
libraries and their popups were blocked too.

It would not have been safe: both endpoints authenticated with an ACCESS token
and neither checked who it was issued for. POST /auth/google fell back to
tokeninfo?access_token= and read the email out of the reply; POST
/auth/microsoft handed the bearer token to Graph /me and trusted that. Graph
and tokeninfo will both 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 have replayed their token here and been issued a session as
them. Both endpoints are deleted; nothing is lost, because nothing could reach
them.

Replaced by ONE generic flow — Authorization Code + PKCE (S256), run
server-side, with the provider list resolved through a single function so
per-organization SSO can extend it later without a second login path. Google
and Microsoft become ordinary configured providers; Okta, Keycloak, Authentik,
Auth0 and anything else that speaks OIDC now work with three env vars.

Because the exchange happens server-side the browser never talks to the
provider, so there is no SDK to load, no client id in the page, and no
third-party origin needed in the CSP.

Identity comes from an ID token that must survive: signature against the
provider's JWKS (asymmetric algorithms only — alg:none and HMAC are refused
outright, the latter because the only key we hold is public), `iss` exactly as
discovered, `aud` and `azp` matching our client, `exp`, and a `nonce` this
server minted for that login. State is compared in constant time against a
value in an httpOnly SameSite=Lax cookie, so the callback is CSRF-protected and
survives a restart mid-login.

Account rules are the ones already in place: a verified email is required, an
SSO login never takes over an account that has a password, and a changed `sub`
for a known address is refused rather than handing the account to a recycled
mailbox.

18 new tests, every one describing something the old code would have accepted:
cross-audience tokens, azp mismatch, replayed nonces, alg:none, HMAC forgery,
wrong signing key, expired tokens, a discovery document lying about its issuer,
and a registry that never leaks a client id or secret to the browser.

Verified end to end against Google's real discovery document: the redirect
carries response_type=code, PKCE S256, state and nonce, and every callback
guard rejects as intended (no cookie, wrong state, no code, provider refusal,
unknown provider).

⚠️ TOTP is still not prompted on an SSO login, matching the documented
behaviour of the previous SSO and API-token paths. That is a product decision
and is left unchanged here rather than altered silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
2026-08-10 17:19:28 -05:00

120 lines
4.6 KiB
JavaScript

'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 };