Authenticate against any OIDC provider, for on-premises installs
CI / verify (push) Successful in 2m55s

The seam existed with only a Supabase implementation, so an on-prem deployment
had no way to authenticate. A customer running PIG inside their own network
already has Okta, Entra, Keycloak, Auth0 or Google Workspace; asking them to
stand up a second identity system is a serious adoption tax and in a regulated
environment usually refused outright.

Setting PIG_OIDC_ISSUER is normally the whole configuration — the JWKS is
discovered from the issuer's well-known document. PIG_OIDC_JWKS_URI skips
discovery entirely for an air-gapped network. OIDC takes precedence over
Supabase so an on-prem install can leave the hosted values in its environment
file without them quietly taking over.

Three decisions worth stating:

Discovery is resolved lazily and the FAILURE is not cached. Doing it per
request would put the customer's identity provider on the critical path of
every API call; doing it eagerly at boot would mean their IdP rebooting takes
the CRM down with it. So it happens on first use and retries on the next
request.

The audience check is optional but warned about loudly. Without it, a token the
provider issued for ANY other application in the same tenant verifies here — a
token minted for an unrelated internal tool would be accepted as a PIG session.
It cannot be mandatory because some providers legitimately issue
single-audience tokens.

Email falls back through email, preferred_username and upn, because providers
disagree, but a preferred_username without an "@" is ignored — PIG keys
membership on the address, and a bare username must never become an account
identity.

Also fixed a warning that claimed "authentication is DISABLED" on a correctly
configured OIDC deployment. That is worse than silence: an operator who reads
it on a secure install learns to ignore the warnings. The dev bypass itself was
already correct — it keys on the resolved provider rather than on Supabase.

18 new tests, most of them about what the provider must REFUSE: a foreign
signing key, a foreign issuer, a token for a different application, an expired
token, a token with no subject, and a discovery outage that must not become
permanent. Keys are generated per test and the JWKS is served locally, so they
run offline.

Verified: production refuses to start with neither provider, starts with OIDC
alone, enforces 401 on an unauthenticated request, and warns only about the
genuinely missing admin list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-13 02:32:28 -07:00
parent 54edee30ed
commit c821b2ca07
7 changed files with 453 additions and 84 deletions
+157 -2
View File
@@ -4,8 +4,22 @@
* Providers prove an external identity. They do not decide whether that
* identity belongs to PIG; workspace membership remains a database decision
* in the authenticator and signup route.
*
* Two implementations:
*
* **Supabase** — the hosted deployment. Well-known JWKS path, fixed issuer.
* **OIDC** — any standards-compliant identity provider, which is what an
* on-premises install needs. The customer already runs Okta,
* Entra, Keycloak, Auth0, Authentik or Google Workspace behind
* their VPN; asking them to stand up a second identity system
* to use PIG would be a serious adoption tax, and in a
* regulated environment often simply refused.
*
* Both reduce to the same thing — verify a bearer token, return a stable
* subject and an email — because that is all PIG needs. Everything downstream
* (teams, roles, capabilities) is PIG's own data keyed on that subject.
*/
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
import type { Config } from './config';
export interface VerifiedIdentity {
@@ -14,6 +28,8 @@ export interface VerifiedIdentity {
}
export interface AuthProvider {
/** Human-readable, for startup logging and the health surface. */
readonly name: string;
verifyAccessToken(token: string): Promise<VerifiedIdentity>;
}
@@ -24,6 +40,7 @@ export function createSupabaseAuthProvider(supabaseUrl: string): AuthProvider {
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
return {
name: 'supabase',
async verifyAccessToken(token: string): Promise<VerifiedIdentity> {
const { payload } = await jwtVerify(token, jwks, { issuer });
if (!payload.sub) throw new Error('token has no subject');
@@ -36,8 +53,146 @@ export function createSupabaseAuthProvider(supabaseUrl: string): AuthProvider {
};
}
export interface OidcProviderOptions {
/** The `iss` value the provider stamps into its tokens. */
issuer: string;
/**
* JWKS location. Optional: when omitted it is discovered from
* `${issuer}/.well-known/openid-configuration`, which every compliant
* provider serves. Setting it explicitly avoids one startup fetch and lets
* an air-gapped deployment skip discovery entirely.
*/
jwksUri?: string;
/**
* Expected audience. **Strongly recommended.**
*
* Without it, any token the identity provider issued for *any* application
* in the same tenant will verify here — a token minted for an unrelated
* internal tool would be accepted as a PIG session. `jose` only checks the
* audience when asked to, so leaving this unset is a real hole rather than a
* relaxed default, and it is warned about at boot.
*/
audience?: string;
/**
* Claim to read the email from. Providers disagree: most use `email`, some
* corporate Entra configurations use `preferred_username` or `upn`. Each
* candidate is tried in order.
*/
emailClaims?: string[];
/** Tolerance for clock skew between PIG and the provider. */
clockToleranceSeconds?: number;
}
const DEFAULT_EMAIL_CLAIMS = ['email', 'preferred_username', 'upn'];
export function createOidcAuthProvider(options: OidcProviderOptions): AuthProvider {
const issuer = options.issuer.replace(/\/+$/, '');
const emailClaims = options.emailClaims?.length ? options.emailClaims : DEFAULT_EMAIL_CLAIMS;
/*
* Resolved once, lazily, and cached — including the failure.
*
* Discovery is a network call, so doing it per request would put the
* identity provider on the critical path of every API call. Doing it eagerly
* at boot would mean PIG refuses to start if the provider is briefly
* unreachable, which on a customer's own network is a bad trade: their
* identity provider rebooting should not take the CRM down with it.
*
* So it happens on first use and is retried on the next request if it fails.
*/
let jwksPromise: Promise<ReturnType<typeof createRemoteJWKSet>> | null = null;
async function resolveJwks() {
if (options.jwksUri) return createRemoteJWKSet(new URL(options.jwksUri));
const discoveryUrl = `${issuer}/.well-known/openid-configuration`;
const response = await fetch(discoveryUrl, { headers: { accept: 'application/json' } });
if (!response.ok) {
throw new Error(
`OIDC discovery failed: ${discoveryUrl} returned ${response.status}. ` +
'Set PIG_OIDC_JWKS_URI to skip discovery.',
);
}
const document = (await response.json()) as { jwks_uri?: string; issuer?: string };
if (!document.jwks_uri) {
throw new Error(`OIDC discovery document at ${discoveryUrl} has no jwks_uri.`);
}
// A discovery document whose issuer disagrees with the configured one means
// the deployment is pointed somewhere unexpected. Verification would fail
// later anyway; failing here says why.
if (document.issuer && document.issuer.replace(/\/+$/, '') !== issuer) {
throw new Error(
`OIDC issuer mismatch: configured ${issuer}, discovery reports ${document.issuer}.`,
);
}
return createRemoteJWKSet(new URL(document.jwks_uri));
}
return {
name: 'oidc',
async verifyAccessToken(token: string): Promise<VerifiedIdentity> {
if (!jwksPromise) {
jwksPromise = resolveJwks().catch((error) => {
// Clear the cache so the next request retries rather than being
// stuck with a rejected promise for the process lifetime.
jwksPromise = null;
throw error;
});
}
const jwks = await jwksPromise;
const { payload } = await jwtVerify(token, jwks, {
issuer,
...(options.audience ? { audience: options.audience } : {}),
clockTolerance: options.clockToleranceSeconds ?? 5,
});
if (!payload.sub) throw new Error('token has no subject');
return { subject: payload.sub, email: readEmail(payload, emailClaims) };
},
};
}
function readEmail(payload: JWTPayload, claims: string[]): string | undefined {
for (const claim of claims) {
const value = payload[claim];
// A `preferred_username` is not always an address; only take it if it
// looks like one, so a bare username never becomes an account identity.
if (typeof value === 'string' && value.includes('@')) return value.toLowerCase();
}
return undefined;
}
/**
* Build the provider this deployment is configured for.
*
* OIDC wins when both are set, so an on-premises install can keep the Supabase
* values in its environment file without them quietly taking precedence.
*/
export function createConfiguredAuthProvider(
config: Pick<Config, 'SUPABASE_URL'>,
config: Pick<
Config,
| 'SUPABASE_URL'
| 'PIG_OIDC_ISSUER'
| 'PIG_OIDC_JWKS_URI'
| 'PIG_OIDC_AUDIENCE'
| 'PIG_OIDC_EMAIL_CLAIMS'
>,
): AuthProvider | null {
if (config.PIG_OIDC_ISSUER) {
return createOidcAuthProvider({
issuer: config.PIG_OIDC_ISSUER,
jwksUri: config.PIG_OIDC_JWKS_URI || undefined,
audience: config.PIG_OIDC_AUDIENCE || undefined,
emailClaims: config.PIG_OIDC_EMAIL_CLAIMS
? config.PIG_OIDC_EMAIL_CLAIMS.split(',').map((claim) => claim.trim()).filter(Boolean)
: undefined,
});
}
return config.SUPABASE_URL ? createSupabaseAuthProvider(config.SUPABASE_URL) : null;
}