feat(auth): central Keycloak portal-trust #449
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user