Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c821b2ca07 | |||
| 54edee30ed | |||
| 6bd5526675 |
@@ -22,6 +22,27 @@ SUPABASE_ANON_KEY=
|
|||||||
# PIG runs fine in invite-only mode. Treat it as the most powerful secret here.
|
# PIG runs fine in invite-only mode. Treat it as the most powerful secret here.
|
||||||
SUPABASE_SERVICE_KEY=
|
SUPABASE_SERVICE_KEY=
|
||||||
|
|
||||||
|
# --- Auth: on-premises (OIDC) ---------------------------------------------
|
||||||
|
# Set PIG_OIDC_ISSUER to authenticate against your own identity provider —
|
||||||
|
# Okta, Entra, Keycloak, Auth0, Authentik, Google Workspace, anything
|
||||||
|
# standards-compliant. It TAKES PRECEDENCE over the Supabase values above, so
|
||||||
|
# an on-prem install can leave those in place.
|
||||||
|
#
|
||||||
|
# PIG never sees a password. It verifies the token your provider issued and
|
||||||
|
# reads two things: a stable subject, and an email. Everything else — teams,
|
||||||
|
# roles, capabilities — is PIG's own data keyed on that subject, so users are
|
||||||
|
# provisioned in PIG by invite, not by your directory.
|
||||||
|
PIG_OIDC_ISSUER=
|
||||||
|
# Optional. Discovered from the issuer's /.well-known/openid-configuration when
|
||||||
|
# omitted. Set it to skip discovery entirely on an air-gapped network.
|
||||||
|
PIG_OIDC_JWKS_URI=
|
||||||
|
# STRONGLY recommended. Without it, a token your provider issued for ANY other
|
||||||
|
# application in the same tenant is accepted here as a PIG session.
|
||||||
|
PIG_OIDC_AUDIENCE=
|
||||||
|
# Comma-separated, in preference order. Defaults to email,preferred_username,upn
|
||||||
|
# which covers most providers; Entra sometimes needs upn first.
|
||||||
|
PIG_OIDC_EMAIL_CLAIMS=
|
||||||
|
|
||||||
# --- Application ------------------------------------------------------------
|
# --- Application ------------------------------------------------------------
|
||||||
PIG_PORT=8920
|
PIG_PORT=8920
|
||||||
PIG_PUBLIC_URL=http://localhost:8920
|
PIG_PUBLIC_URL=http://localhost:8920
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ in the service layer or in the agent. The API signals the agent by *writing a
|
|||||||
row to `agent_tasks`*, never by calling it — so the queue survives the agent
|
row to `agent_tasks`*, never by calling it — so the queue survives the agent
|
||||||
being down and no request thread ever blocks on a model.
|
being down and no request thread ever blocks on a model.
|
||||||
|
|
||||||
|
**There are two auth providers, behind one interface.** Supabase for the
|
||||||
|
hosted deployment, OIDC for on-premises — see `apps/api/src/lib/auth-provider.ts`.
|
||||||
|
Both reduce to "verify a bearer token, return a subject and an email", because
|
||||||
|
that is all PIG needs. Never reach for a provider SDK outside that file.
|
||||||
|
|
||||||
**Authentication is not authorization.** A verified JWT proves someone has an
|
**Authentication is not authorization.** A verified JWT proves someone has an
|
||||||
account in an identity provider that PIG *shares with another application*. It
|
account in an identity provider that PIG *shares with another application*. It
|
||||||
does not prove they belong here. Access requires a row in PIG's own `users`
|
does not prove they belong here. Access requires a row in PIG's own `users`
|
||||||
@@ -135,9 +140,19 @@ conflict on, do an existence check instead.
|
|||||||
flag set to false was silently on. Use the `envBoolean` helper in
|
flag set to false was silently on. Use the `envBoolean` helper in
|
||||||
`apps/api/src/lib/config.ts`.
|
`apps/api/src/lib/config.ts`.
|
||||||
|
|
||||||
**Grid children that truncate need `min-w-0`.** Grid items default to
|
**Grid and flex children need `min-w-0`.** They default to
|
||||||
`min-width: auto` and `truncate` sets `nowrap`, so a long title becomes
|
`min-width: auto`, meaning they refuse to shrink below their content — and a
|
||||||
unshrinkable content and the page scrolls sideways on a phone.
|
`tabular-nums` figure, a `whitespace-nowrap` badge or a `truncate` title is all
|
||||||
|
it takes. The page then scrolls sideways on a phone and nothing reports an
|
||||||
|
error. This was fixed three separate times at individual call sites before
|
||||||
|
`Card` was given `min-w-0` on its base class; **any new container primitive
|
||||||
|
needs the same**. Check with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
document.documentElement.scrollWidth - document.documentElement.clientWidth
|
||||||
|
```
|
||||||
|
|
||||||
|
It should be 0 on every route at 393px wide.
|
||||||
|
|
||||||
**Drizzle-generated migrations are not always valid SQL.** A `jsonb → integer`
|
**Drizzle-generated migrations are not always valid SQL.** A `jsonb → integer`
|
||||||
cast was emitted without the `USING` clause Postgres requires. Always apply a
|
cast was emitted without the `USING` clause Postgres requires. Always apply a
|
||||||
@@ -155,6 +170,13 @@ Verified: 1× A100 at 1.79, 2× A100 at 3.58. `gpuMemory` is likewise a node
|
|||||||
total. There is an open bug for this — the mapper currently stores both as if
|
total. There is an open bug for this — the mapper currently stores both as if
|
||||||
per-GPU, so an 8-GPU node reads eight times too expensive.
|
per-GPU, so an 8-GPU node reads eight times too expensive.
|
||||||
|
|
||||||
|
**The SPA fallback must never answer an `/api/` path.** Without an explicit
|
||||||
|
guard, an unknown API route returns `200 text/html` — the app shell — and the
|
||||||
|
caller sees `response.ok === true` before failing on `JSON.parse` with
|
||||||
|
"Unexpected token '<'", a long way from the cause. Both the static-file
|
||||||
|
middleware and the SPA fallback in `apps/api/src/server.ts` carry the guard;
|
||||||
|
anything added after them needs it too.
|
||||||
|
|
||||||
**Prime Intellect has two API hosts.** `api.primeintellect.ai` is compute and
|
**Prime Intellect has two API hosts.** `api.primeintellect.ai` is compute and
|
||||||
pods. Inference is `api.pinference.ai/api/v1`, OpenAI-compatible.
|
pods. Inference is `api.pinference.ai/api/v1`, OpenAI-compatible.
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ test('invite-bound member creates, sells and observes capacity through authentic
|
|||||||
PIGGY_ENABLED: 'false',
|
PIGGY_ENABLED: 'false',
|
||||||
});
|
});
|
||||||
const authProvider = {
|
const authProvider = {
|
||||||
|
name: 'e2e-stub',
|
||||||
async verifyAccessToken(token: string) {
|
async verifyAccessToken(token: string) {
|
||||||
if (token !== accessToken) throw new Error('Invalid E2E token.');
|
if (token !== accessToken) throw new Error('Invalid E2E token.');
|
||||||
return { subject, email };
|
return { subject, email };
|
||||||
|
|||||||
@@ -4,8 +4,22 @@
|
|||||||
* Providers prove an external identity. They do not decide whether that
|
* Providers prove an external identity. They do not decide whether that
|
||||||
* identity belongs to PIG; workspace membership remains a database decision
|
* identity belongs to PIG; workspace membership remains a database decision
|
||||||
* in the authenticator and signup route.
|
* 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';
|
import type { Config } from './config';
|
||||||
|
|
||||||
export interface VerifiedIdentity {
|
export interface VerifiedIdentity {
|
||||||
@@ -14,6 +28,8 @@ export interface VerifiedIdentity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthProvider {
|
export interface AuthProvider {
|
||||||
|
/** Human-readable, for startup logging and the health surface. */
|
||||||
|
readonly name: string;
|
||||||
verifyAccessToken(token: string): Promise<VerifiedIdentity>;
|
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`));
|
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
name: 'supabase',
|
||||||
async verifyAccessToken(token: string): Promise<VerifiedIdentity> {
|
async verifyAccessToken(token: string): Promise<VerifiedIdentity> {
|
||||||
const { payload } = await jwtVerify(token, jwks, { issuer });
|
const { payload } = await jwtVerify(token, jwks, { issuer });
|
||||||
if (!payload.sub) throw new Error('token has no subject');
|
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(
|
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 {
|
): 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;
|
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.'),
|
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required.'),
|
||||||
|
|
||||||
SUPABASE_URL: z.string().url().optional(),
|
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_ANON_KEY: z.string().optional(),
|
||||||
SUPABASE_SERVICE_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.');
|
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(
|
warn(
|
||||||
'SUPABASE_URL is not set — authentication is DISABLED and every request ' +
|
'No identity provider is configured (SUPABASE_URL or PIG_OIDC_ISSUER) — ' +
|
||||||
'runs as the development user. Never do this in production.',
|
'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(
|
throw new Error(
|
||||||
'Refusing to start: NODE_ENV=production with no SUPABASE_URL would serve ' +
|
'Refusing to start: NODE_ENV=production with neither SUPABASE_URL nor ' +
|
||||||
'the entire CRM unauthenticated.',
|
'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);
|
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.
|
* Client-side routes (/margin, /capacity, …) have no file on disk and must
|
||||||
app.get('*', serveStatic({ path: './apps/web/dist/index.html' }));
|
* 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);
|
console.log('[pig] serving front end from', webDist);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +1,214 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for the identity-provider boundary.
|
* Tests for the OIDC authentication provider.
|
||||||
*
|
*
|
||||||
* These cases pin the trust decisions shared by protected requests and profile
|
* This is the code that decides whether a stranger is who they claim to be, so
|
||||||
* creation. Membership remains deliberately outside this module.
|
* the cases below are mostly about what it must **refuse**. A provider that
|
||||||
|
* accepts a token it should not is not a bug you find in staging.
|
||||||
|
*
|
||||||
|
* Keys are generated per test and the JWKS is served from a local HTTP server,
|
||||||
|
* so these run offline and deterministically — no network, no fixtures that
|
||||||
|
* expire.
|
||||||
*/
|
*/
|
||||||
import { strict as assert } from 'node:assert';
|
import { strict as assert } from 'node:assert';
|
||||||
import { generateKeyPairSync, type KeyObject } from 'node:crypto';
|
import { after, before, describe, it } from 'node:test';
|
||||||
import { createServer, type Server } from 'node:http';
|
import { createServer, type Server } from 'node:http';
|
||||||
import type { AddressInfo } from 'node:net';
|
import type { AddressInfo } from 'node:net';
|
||||||
import { after, before, describe, it } from 'node:test';
|
import { SignJWT, exportJWK, generateKeyPair, type JWK } from 'jose';
|
||||||
import { exportJWK, SignJWT } from 'jose';
|
import { createOidcAuthProvider, createConfiguredAuthProvider } from '../src/lib/auth-provider';
|
||||||
import {
|
|
||||||
createSupabaseAuthProvider,
|
|
||||||
type AuthProvider,
|
|
||||||
} from '../src/lib/auth-provider';
|
|
||||||
|
|
||||||
describe('Supabase auth provider', () => {
|
let server: Server;
|
||||||
let server: Server;
|
let origin: string;
|
||||||
let provider: AuthProvider;
|
// Derived from jose rather than referencing the DOM `CryptoKey` type, which
|
||||||
let issuer: string;
|
// is not in this package's type lib.
|
||||||
let privateKey: KeyObject;
|
type SigningKey = Awaited<ReturnType<typeof generateKeyPair>>['privateKey'];
|
||||||
|
|
||||||
before(async () => {
|
let privateKey: SigningKey;
|
||||||
const keys = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
let otherPrivateKey: SigningKey;
|
||||||
privateKey = keys.privateKey;
|
let jwks: { keys: JWK[] };
|
||||||
const publicJwk = await exportJWK(keys.publicKey);
|
/** Flipped per test to exercise discovery failures. */
|
||||||
|
let discoveryStatus = 200;
|
||||||
|
let serveDiscovery = true;
|
||||||
|
|
||||||
server = createServer((request, response) => {
|
before(async () => {
|
||||||
if (request.url !== '/auth/v1/.well-known/jwks.json') {
|
const pair = await generateKeyPair('RS256');
|
||||||
response.writeHead(404).end();
|
const other = await generateKeyPair('RS256');
|
||||||
|
privateKey = pair.privateKey;
|
||||||
|
otherPrivateKey = other.privateKey;
|
||||||
|
|
||||||
|
const publicJwk = await exportJWK(pair.publicKey);
|
||||||
|
publicJwk.kid = 'test-key';
|
||||||
|
publicJwk.alg = 'RS256';
|
||||||
|
jwks = { keys: [publicJwk] };
|
||||||
|
|
||||||
|
server = createServer((req, res) => {
|
||||||
|
if (req.url === '/.well-known/openid-configuration') {
|
||||||
|
if (!serveDiscovery || discoveryStatus !== 200) {
|
||||||
|
res.writeHead(discoveryStatus === 200 ? 404 : discoveryStatus);
|
||||||
|
res.end('{}');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
response.setHeader('content-type', 'application/json');
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
response.end(
|
res.end(JSON.stringify({ issuer: origin, jwks_uri: `${origin}/jwks` }));
|
||||||
JSON.stringify({
|
return;
|
||||||
keys: [{ ...publicJwk, alg: 'RS256', kid: 'test-key', use: 'sig' }],
|
}
|
||||||
}),
|
if (req.url === '/jwks') {
|
||||||
);
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(jwks));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
server.once('error', reject);
|
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||||
server.listen(0, '127.0.0.1', resolve);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const address = server.address() as AddressInfo;
|
after(() => server?.close());
|
||||||
const supabaseUrl = `http://127.0.0.1:${address.port}`;
|
|
||||||
issuer = `${supabaseUrl}/auth/v1`;
|
|
||||||
provider = createSupabaseAuthProvider(supabaseUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
after(
|
async function mint(
|
||||||
() =>
|
claims: Record<string, unknown>,
|
||||||
new Promise<void>((resolve, reject) => {
|
opts: { key?: SigningKey; issuer?: string; expiresIn?: string } = {},
|
||||||
server.close((error) => (error ? reject(error) : resolve()));
|
) {
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
async function sign(claims: Record<string, unknown>, tokenIssuer = issuer): Promise<string> {
|
|
||||||
return new SignJWT(claims)
|
return new SignJWT(claims)
|
||||||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
||||||
.setIssuer(tokenIssuer)
|
.setIssuedAt()
|
||||||
.setExpirationTime('5m')
|
.setIssuer(opts.issuer ?? origin)
|
||||||
.sign(privateKey);
|
.setExpirationTime(opts.expiresIn ?? '5m')
|
||||||
|
.sign(opts.key ?? privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OIDC provider — what it accepts', () => {
|
||||||
|
it('verifies a well-formed token and returns subject and email', async () => {
|
||||||
|
const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
|
||||||
|
const token = await mint({ sub: 'user-1', email: 'Person@Example.com', aud: 'pig' });
|
||||||
|
const identity = await provider.verifyAccessToken(token);
|
||||||
|
|
||||||
|
assert.equal(identity.subject, 'user-1');
|
||||||
|
// Lower-cased, because PIG keys membership on the address and
|
||||||
|
// Person@ and person@ are the same person.
|
||||||
|
assert.equal(identity.email, 'person@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discovers the JWKS from the issuer when no explicit URI is given', async () => {
|
||||||
|
const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
|
||||||
|
const token = await mint({ sub: 'user-2', email: 'a@b.com', aud: 'pig' });
|
||||||
|
assert.equal((await provider.verifyAccessToken(token)).subject, 'user-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips discovery entirely when the JWKS URI is configured', async () => {
|
||||||
|
// The air-gapped path: no discovery request is made at all.
|
||||||
|
serveDiscovery = false;
|
||||||
|
try {
|
||||||
|
const provider = createOidcAuthProvider({
|
||||||
|
issuer: origin,
|
||||||
|
jwksUri: `${origin}/jwks`,
|
||||||
|
audience: 'pig',
|
||||||
|
});
|
||||||
|
const token = await mint({ sub: 'user-3', email: 'a@b.com', aud: 'pig' });
|
||||||
|
assert.equal((await provider.verifyAccessToken(token)).subject, 'user-3');
|
||||||
|
} finally {
|
||||||
|
serveDiscovery = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
it('returns only the verified external identity claims', async () => {
|
|
||||||
const token = await sign({ sub: 'provider-user-1', email: 'Owner@Example.com' });
|
|
||||||
|
|
||||||
assert.deepEqual(await provider.verifyAccessToken(token), {
|
|
||||||
subject: 'provider-user-1',
|
|
||||||
email: 'Owner@Example.com',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a correctly signed token issued for a different identity provider', async () => {
|
it('falls back through email claims for providers that do not send `email`', async () => {
|
||||||
// Signature validity alone is insufficient: without the issuer check, a
|
const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
|
||||||
// sibling deployment using the same key could authenticate here.
|
const token = await mint({ sub: 'user-4', preferred_username: 'someone@corp.com', aud: 'pig' });
|
||||||
const token = await sign({ sub: 'provider-user-1' }, 'https://other.example/auth/v1');
|
assert.equal((await provider.verifyAccessToken(token)).email, 'someone@corp.com');
|
||||||
|
|
||||||
await assert.rejects(provider.verifyAccessToken(token));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts a subject without email for protected requests', async () => {
|
it('ignores a preferred_username that is not an address', async () => {
|
||||||
// Existing members are joined by subject. Email is required only by the
|
// A bare username must never become an account identity — PIG keys
|
||||||
// invite-gated profile flow, not as an extra condition on every request.
|
// membership on the email, and "jsmith" is not one.
|
||||||
const token = await sign({ sub: 'provider-user-2' });
|
const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
|
||||||
|
const token = await mint({ sub: 'user-5', preferred_username: 'jsmith', aud: 'pig' });
|
||||||
assert.deepEqual(await provider.verifyAccessToken(token), {
|
assert.equal((await provider.verifyAccessToken(token)).email, undefined);
|
||||||
subject: 'provider-user-2',
|
});
|
||||||
email: undefined,
|
});
|
||||||
});
|
|
||||||
});
|
describe('OIDC provider — what it must refuse', () => {
|
||||||
|
const provider = () => createOidcAuthProvider({ issuer: origin, audience: 'pig' });
|
||||||
it('rejects a token with no stable subject', async () => {
|
|
||||||
const token = await sign({ email: 'owner@example.com' });
|
it('rejects a token signed by a different key', async () => {
|
||||||
|
const token = await mint({ sub: 'x', aud: 'pig' }, { key: otherPrivateKey });
|
||||||
await assert.rejects(provider.verifyAccessToken(token));
|
await assert.rejects(() => provider().verifyAccessToken(token));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a token from a different issuer', async () => {
|
||||||
|
const token = await mint({ sub: 'x', aud: 'pig' }, { issuer: 'https://evil.example' });
|
||||||
|
await assert.rejects(() => provider().verifyAccessToken(token));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a token minted for a DIFFERENT application in the same tenant', async () => {
|
||||||
|
// The case the audience check exists for. Without it, a token issued for
|
||||||
|
// any other internal tool would be accepted as a PIG session.
|
||||||
|
const token = await mint({ sub: 'x', aud: 'some-other-app' });
|
||||||
|
await assert.rejects(() => provider().verifyAccessToken(token));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an expired token', async () => {
|
||||||
|
const token = await mint({ sub: 'x', aud: 'pig' }, { expiresIn: '-1m' });
|
||||||
|
await assert.rejects(() => provider().verifyAccessToken(token));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a token with no subject', async () => {
|
||||||
|
const token = await mint({ aud: 'pig' });
|
||||||
|
await assert.rejects(() => provider().verifyAccessToken(token));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects garbage', async () => {
|
||||||
|
await assert.rejects(() => provider().verifyAccessToken('not-a-token'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a discovery failure and retries on the next call', async () => {
|
||||||
|
const p = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
|
||||||
|
const token = await mint({ sub: 'user-6', aud: 'pig' });
|
||||||
|
|
||||||
|
discoveryStatus = 503;
|
||||||
|
await assert.rejects(() => p.verifyAccessToken(token));
|
||||||
|
|
||||||
|
// The failure must not be cached for the process lifetime: an identity
|
||||||
|
// provider that reboots should not permanently break PIG.
|
||||||
|
discoveryStatus = 200;
|
||||||
|
assert.equal((await p.verifyAccessToken(token)).subject, 'user-6');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createConfiguredAuthProvider', () => {
|
||||||
|
it('prefers OIDC when both are configured', () => {
|
||||||
|
const provider = createConfiguredAuthProvider({
|
||||||
|
SUPABASE_URL: 'https://project.supabase.co',
|
||||||
|
PIG_OIDC_ISSUER: 'https://id.customer.internal',
|
||||||
|
PIG_OIDC_JWKS_URI: undefined,
|
||||||
|
PIG_OIDC_AUDIENCE: 'pig',
|
||||||
|
PIG_OIDC_EMAIL_CLAIMS: undefined,
|
||||||
|
});
|
||||||
|
// So an on-prem install can leave the hosted values in place without them
|
||||||
|
// silently taking over.
|
||||||
|
assert.equal(provider?.name, 'oidc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses Supabase when only it is configured', () => {
|
||||||
|
const provider = createConfiguredAuthProvider({
|
||||||
|
SUPABASE_URL: 'https://project.supabase.co',
|
||||||
|
PIG_OIDC_ISSUER: undefined,
|
||||||
|
PIG_OIDC_JWKS_URI: undefined,
|
||||||
|
PIG_OIDC_AUDIENCE: undefined,
|
||||||
|
PIG_OIDC_EMAIL_CLAIMS: undefined,
|
||||||
|
});
|
||||||
|
assert.equal(provider?.name, 'supabase');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when neither is configured', () => {
|
||||||
|
const provider = createConfiguredAuthProvider({
|
||||||
|
SUPABASE_URL: undefined,
|
||||||
|
PIG_OIDC_ISSUER: undefined,
|
||||||
|
PIG_OIDC_JWKS_URI: undefined,
|
||||||
|
PIG_OIDC_AUDIENCE: undefined,
|
||||||
|
PIG_OIDC_EMAIL_CLAIMS: undefined,
|
||||||
|
});
|
||||||
|
// The production guard in config.ts turns this into a refusal to start.
|
||||||
|
assert.equal(provider, null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Regular → Executable
@@ -83,8 +83,22 @@ Input.displayName = 'Input';
|
|||||||
|
|
||||||
// --------------------------------------------------------------------- card
|
// --------------------------------------------------------------------- card
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `min-w-0` is on the base class deliberately, not left to each call site.
|
||||||
|
*
|
||||||
|
* A grid or flex child defaults to `min-width: auto`, meaning it refuses to
|
||||||
|
* shrink below its content — and cards routinely contain something
|
||||||
|
* unshrinkable (a `tabular-nums` figure, a `whitespace-nowrap` badge, a long
|
||||||
|
* unbroken title). The result is a card wider than its column, which drags the
|
||||||
|
* whole page into horizontal scrolling on a phone.
|
||||||
|
*
|
||||||
|
* This has now been fixed three separate times at individual call sites, which
|
||||||
|
* is the signal that it belongs here instead. `min-width: 0` is inert for a
|
||||||
|
* block-level card outside a flex or grid container, so applying it always
|
||||||
|
* costs nothing and removes the entire class of bug.
|
||||||
|
*/
|
||||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
return <div className={cn('card', className)} {...props} />;
|
return <div className={cn('card min-w-0', className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
|||||||
@@ -98,6 +98,34 @@ applied. Take a dump before a major upgrade anyway:
|
|||||||
docker compose -p pig exec db pg_dump -U pig pig | gzip > pig-$(date +%F).sql.gz
|
docker compose -p pig exec db pg_dump -U pig pig | gzip > pig-$(date +%F).sql.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## On-premises: using your own identity provider
|
||||||
|
|
||||||
|
PIG authenticates against any standards-compliant OIDC provider, which is how
|
||||||
|
an install inside your own network works. Set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PIG_OIDC_ISSUER=https://id.yourcompany.internal
|
||||||
|
PIG_OIDC_AUDIENCE=pig # the client/app id you registered for PIG
|
||||||
|
```
|
||||||
|
|
||||||
|
That is usually the whole configuration — the JWKS is discovered from the
|
||||||
|
issuer. On an air-gapped network, set `PIG_OIDC_JWKS_URI` too and no discovery
|
||||||
|
request is made.
|
||||||
|
|
||||||
|
`PIG_OIDC_ISSUER` takes precedence over `SUPABASE_URL`, so the hosted values
|
||||||
|
can stay in the environment file without quietly taking over.
|
||||||
|
|
||||||
|
**Set the audience.** Without it, any token your provider issued for any
|
||||||
|
application in the same tenant verifies here — a token minted for an unrelated
|
||||||
|
internal tool would be accepted as a PIG session. PIG warns about this at boot
|
||||||
|
but cannot refuse, because some providers legitimately issue single-audience
|
||||||
|
tokens.
|
||||||
|
|
||||||
|
**Provisioning stays in PIG.** Authenticating proves who someone is; it does
|
||||||
|
not make them a member. They still need an invite, and their team and role live
|
||||||
|
in PIG's database. That is deliberate — your directory should not have to model
|
||||||
|
"supply lead versus demand member" for one application.
|
||||||
|
|
||||||
## A note on the auth project
|
## A note on the auth project
|
||||||
|
|
||||||
PIG verifies JWTs but authorizes from its own `users` table. If the Supabase
|
PIG verifies JWTs but authorizes from its own `users` table. If the Supabase
|
||||||
|
|||||||
Reference in New Issue
Block a user