feat(auth): central Keycloak portal-trust #449

Merged
siax-bot merged 1 commits from fix/keycloak-portal-trust-20261102 into main 2026-11-02 17:47:25 +00:00
2 changed files with 58 additions and 0 deletions
+5
View File
@@ -279,3 +279,8 @@ OPT_CREDIT_PRICE_BOOK=
# explicit (never a silent fallback), the response reports `credits: null`, and
# purchases stay disabled. Unset/`on` requires the price book + ledger.
OPT_CREDITS_MODE=
# Central Keycloak (SIAX Portal)
KEYCLOAK_ISSUER=https://auth.portal.siax.dev/realms/portal
KEYCLOAK_EXPECTED_AUDIENCE=account
KEYCLOAK_AUTHORIZED_PARTY=siax-portal
+53
View File
@@ -45,6 +45,7 @@
import {
createRemoteJWKSet,
decodeJwt,
jwtVerify,
type JWTPayload,
type JWTVerifyGetKey,
@@ -53,6 +54,33 @@ import {
/** Default fleet Zitadel issuer (OIDC). Overridable via ZITADEL_ISSUER. */
export const DEFAULT_ZITADEL_ISSUER = 'https://id-customers.siax.io';
/** Central Keycloak (SIAX Portal) trust; additive, unset keeps legacy behaviour. */
export function keycloakIssuer(): string | undefined {
const raw = process.env.KEYCLOAK_ISSUER?.trim().replace(/\/+$/, '');
return raw && raw.length > 0 ? raw : undefined;
}
export function keycloakAudiences(): string[] {
const raw = process.env.KEYCLOAK_EXPECTED_AUDIENCE?.trim();
if (!raw) return ['account'];
const list = raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
return list.length > 0 ? list : ['account'];
}
export function keycloakAuthorizedParty(): string | undefined {
const raw = process.env.KEYCLOAK_AUTHORIZED_PARTY?.trim();
return raw && raw.length > 0 ? raw : undefined;
}
function tokenIssuerUnsafe(token: string): string | undefined {
try {
const iss = decodeJwt(token).iss;
return typeof iss === 'string' ? iss : undefined;
} catch {
return undefined;
}
}
/** Standard + custom claim paths tenant derivation reads, in precedence order. */
export const ZITADEL_ORG_ID_CLAIM = 'urn:zitadel:iam:org:id';
export const ZITADEL_RESOURCEOWNER_CLAIM = 'urn:zitadel:iam:user:resourceowner:id';
@@ -190,6 +218,31 @@ export async function verifyBearer(
token: string,
opts: VerifyBearerOptions = {},
): Promise<VerifiedPrincipal> {
// Central Keycloak (SIAX Portal) user tokens, routed by issuer.
const kcIssuer = keycloakIssuer();
if (kcIssuer !== undefined && tokenIssuerUnsafe(token) === kcIssuer) {
const resolver = opts.keyResolver ?? keyResolverOverride ?? (await getRemoteJwks(kcIssuer));
let payload: JWTPayload;
try {
({ payload } = await jwtVerify(token, resolver, {
issuer: kcIssuer,
audience: keycloakAudiences(),
}));
} catch (err) {
throw new BearerVerificationError('keycloak_token_verification_failed', err);
}
const azp = typeof payload.azp === 'string' ? payload.azp : '';
const expectedAzp = keycloakAuthorizedParty();
if (expectedAzp !== undefined && azp !== expectedAzp) {
throw new BearerVerificationError('keycloak_azp_mismatch');
}
const sub = typeof payload.sub === 'string' ? payload.sub : undefined;
if (!sub) throw new BearerVerificationError('token_missing_sub');
const tenantId = deriveTenantId(payload, opts.tenantClaim ?? tenantClaimOverride());
if (!tenantId) throw new BearerVerificationError('token_missing_tenant_claim');
return { sub, tenantId, claims: payload };
}
const issuer = opts.issuer ?? zitadelIssuer();
const resolver = opts.keyResolver ?? keyResolverOverride ?? (await getRemoteJwks(issuer));