diff --git a/.env.example b/.env.example index a7240d6..7b34f61 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/src/auth/zitadel.ts b/src/auth/zitadel.ts index 25b4e86..5184710 100644 --- a/src/auth/zitadel.ts +++ b/src/auth/zitadel.ts @@ -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 { + // 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));