mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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
120 lines
4.6 KiB
JavaScript
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 };
|