Merge remote-tracking branch 'gitea/main' into feat/revenue-intelligence
CI / verify (push) Successful in 2m54s
CI / verify (push) Successful in 2m54s
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user