mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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
This commit is contained in:
parent
28885d1a13
commit
252854d31e
62
README.md
62
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/<slug>/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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 <img> 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: `<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>`,
|
||||
microsoft: `<svg width="18" height="18" viewBox="0 0 21 21" aria-hidden="true">
|
||||
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
|
||||
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
|
||||
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
|
||||
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
|
||||
</svg>`,
|
||||
};
|
||||
|
||||
const GENERIC_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>`;
|
||||
|
||||
const providerIcon = (slug) => PROVIDER_ICONS[slug] || GENERIC_ICON;
|
||||
|
||||
let authConfig = null;
|
||||
|
||||
async function loadAuthConfig() {
|
||||
|
|
@ -148,7 +175,7 @@ export async function render(container) {
|
|||
</div>
|
||||
|
||||
<div id="ssoBlock">
|
||||
${config.googleEnabled || config.microsoftEnabled ? `
|
||||
${(config.providers || []).length ? `
|
||||
<div style="display:flex;align-items:center;gap:12px;margin:20px 0">
|
||||
<hr style="flex:1;border-color:var(--border)">
|
||||
<span style="color:var(--text-muted);font-size:12px">${t('auth.divider_or')}</span>
|
||||
|
|
@ -156,31 +183,20 @@ export async function render(container) {
|
|||
</div>
|
||||
` : ''}
|
||||
|
||||
${config.googleEnabled ? `
|
||||
<div id="googleSignInContainer">
|
||||
<button class="btn btn-secondary" id="googleSignInBtn" style="width:100%;justify-content:center;padding:10px;gap:8px">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
${t('auth.signin_google')}
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${config.microsoftEnabled ? `
|
||||
<button class="btn btn-secondary" id="microsoftSignInBtn" style="width:100%;justify-content:center;padding:10px;gap:8px;margin-top:8px">
|
||||
<svg width="18" height="18" viewBox="0 0 21 21">
|
||||
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
|
||||
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
|
||||
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
|
||||
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
|
||||
</svg>
|
||||
${t('auth.signin_microsoft')}
|
||||
</button>
|
||||
` : ''}
|
||||
<!-- One button per configured provider, and each is a plain LINK to a server endpoint.
|
||||
There is no provider SDK on this page: the browser never speaks to the identity
|
||||
provider directly, so nothing here needs a client id and the CSP needs no
|
||||
third-party script origin. Google and Microsoft are ordinary entries in this list.
|
||||
The icon is chosen by slug where we have one and falls back to a generic mark, so a
|
||||
self-hoster's Keycloak or Authentik still gets a real-looking button. -->
|
||||
${(config.providers || []).map((p) => `
|
||||
<a class="btn btn-secondary" href="/api/auth/oidc/${encodeURIComponent(p.slug)}/start"
|
||||
id="sso-${p.slug}"
|
||||
style="width:100%;justify-content:center;padding:10px;gap:8px;margin-top:8px;text-decoration:none">
|
||||
${providerIcon(p.slug)}
|
||||
<span></span>${t('auth.signin_with').replace('{provider}', p.name)}
|
||||
</a>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -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/<slug>/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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
119
server/lib/oidc-providers.js
Normal file
119
server/lib/oidc-providers.js
Normal file
|
|
@ -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 };
|
||||
224
server/lib/oidc.js
Normal file
224
server/lib/oidc.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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/<slug>/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;
|
||||
|
|
|
|||
262
server/test/oidc-sso.test.js
Normal file
262
server/test/oidc-sso.test.js
Normal file
|
|
@ -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']);
|
||||
});
|
||||
Loading…
Reference in a new issue