Merge remote-tracking branch 'gitea/main' into feat/revenue-intelligence
CI / verify (push) Successful in 2m54s

This commit is contained in:
2026-08-13 03:50:26 -07:00
24 changed files with 786 additions and 126 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;
}
+52 -6
View File
@@ -33,6 +33,21 @@ const schema = z.object({
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required.'),
SUPABASE_URL: z.string().url().optional(),
/*
* OIDC — the on-premises path.
*
* A customer running PIG inside their own network already has an identity
* provider. Setting PIG_OIDC_ISSUER switches authentication to it and takes
* precedence over any Supabase values left in the environment file.
*/
PIG_OIDC_ISSUER: z.string().url().optional(),
/** Optional. Discovered from the issuer when omitted. */
PIG_OIDC_JWKS_URI: z.string().url().optional(),
/** Strongly recommended — see the boot warning. */
PIG_OIDC_AUDIENCE: z.string().optional(),
/** Comma-separated, in preference order. Defaults cover most providers. */
PIG_OIDC_EMAIL_CLAIMS: z.string().optional(),
SUPABASE_ANON_KEY: z.string().optional(),
SUPABASE_SERVICE_KEY: z.string().optional(),
@@ -203,17 +218,48 @@ function warnOnFootguns(config: Config): void {
warn('PIG_ADMIN_EMAILS is empty — no user will have platform-admin rights.');
}
if (!config.SUPABASE_URL) {
// Keyed on BOTH providers, not just Supabase. An OIDC deployment has
// authentication and this warning previously claimed it did not — which is
// worse than saying nothing, because an operator reading "authentication is
// DISABLED" on a correctly secured install learns to ignore the warnings.
if (!config.SUPABASE_URL && !config.PIG_OIDC_ISSUER) {
warn(
'SUPABASE_URL is not set — authentication is DISABLED and every request ' +
'runs as the development user. Never do this in production.',
'No identity provider is configured (SUPABASE_URL or PIG_OIDC_ISSUER) — ' +
'authentication is DISABLED and every request runs as the development ' +
'user. Never do this in production.',
);
}
if (config.isProduction && !config.SUPABASE_URL) {
if (config.PIG_OIDC_ISSUER && config.SUPABASE_URL) {
warn(
'Both PIG_OIDC_ISSUER and SUPABASE_URL are set — OIDC takes precedence and ' +
'Supabase will not be used for authentication.',
);
}
if (config.PIG_OIDC_ISSUER && !config.PIG_OIDC_AUDIENCE) {
// Not fatal, because some providers issue single-audience tokens where it
// adds nothing — but on a shared corporate tenant this is the difference
// between "a token for PIG" and "a token for anything in the company".
warn(
'PIG_OIDC_AUDIENCE is not set. Any token your identity provider issued for ' +
'ANY application in the same tenant will be accepted here. Set it unless ' +
'you are certain that is safe.',
);
}
if (config.PIG_OIDC_ISSUER && config.SUPABASE_SERVICE_KEY) {
warn(
'SUPABASE_SERVICE_KEY is set while running on OIDC. Self-registration mints ' +
'Supabase accounts, which an OIDC deployment does not use — unset it and ' +
'provision users through your identity provider instead.',
);
}
if (config.isProduction && !config.SUPABASE_URL && !config.PIG_OIDC_ISSUER) {
throw new Error(
'Refusing to start: NODE_ENV=production with no SUPABASE_URL would serve ' +
'the entire CRM unauthenticated.',
'Refusing to start: NODE_ENV=production with neither SUPABASE_URL nor ' +
'PIG_OIDC_ISSUER would serve the entire CRM unauthenticated.',
);
}
+19 -3
View File
@@ -57,9 +57,25 @@ if (existsSync(webDist)) {
return serveStatic({ root: './apps/web/dist' })(c, next);
});
// Client-side routes (/margin, /capacity, …) have no file on disk and must
// receive the shell so the router can take over.
app.get('*', serveStatic({ path: './apps/web/dist/index.html' }));
/*
* Client-side routes (/margin, /capacity, …) have no file on disk and must
* receive the shell so the router can take over.
*
* The `/api/` guard is repeated here deliberately. Without it an unknown API
* path — a typo, a renamed endpoint, an older client — falls through to this
* fallback and returns **HTTP 200 with the SPA's HTML**. That is close to the
* worst possible failure for an API consumer: `response.ok` is true, so
* nothing treats it as an error, and the caller then fails on `JSON.parse`
* with "Unexpected token '<'" a long way from the actual cause. The MCP
* server, the CLI and Piggy all consume this API and would all have hit it.
*
* Confirmed against production before fixing: an authenticated GET to
* /api/keys (the real path is /api/api-keys) returned 200 text/html.
*/
app.get('*', async (c, next) => {
if (new URL(c.req.url).pathname.startsWith('/api/')) return next();
return serveStatic({ path: './apps/web/dist/index.html' })(c, next);
});
console.log('[pig] serving front end from', webDist);
}