diff --git a/packages/services/server/src/environment.ts b/packages/services/server/src/environment.ts index 400865b4fb..c7525e625b 100644 --- a/packages/services/server/src/environment.ts +++ b/packages/services/server/src/environment.ts @@ -1,4 +1,6 @@ +import { readFileSync } from 'node:fs'; import zod from 'zod'; +import { isUUID } from '@hive/api/shared/is-uuid'; import { OpenTelemetryConfigurationModel, parseRedisConfigFromEnvironment, @@ -302,6 +304,50 @@ const LogModel = zod.object({ ), }); +const OidcWorkloadFederationModel = zod.union([ + zod.object({ + OIDC_WORKLOAD_FEDERATION_IDENTITY_PROVIDER: zod.union([ + zod.void(), + zod.literal(''), + zod.literal('0'), + ]), + }), + zod + .object({ + OIDC_WORKLOAD_FEDERATION_IDENTITY_PROVIDER: zod.literal('azure'), + AZURE_FEDERATED_TOKEN_FILE: zod.string().min(1), + OIDC_WORKLOAD_FEDERATION_ORGANIZATION_IDS: zod.string().min(1), + }) + .superRefine((data, ctx) => { + try { + const content = readFileSync(data.AZURE_FEDERATED_TOKEN_FILE, 'utf-8').trim(); + if (!content) { + ctx.addIssue({ + code: zod.ZodIssueCode.custom, + path: ['AZURE_FEDERATED_TOKEN_FILE'], + message: `Federated token file at path '${data.AZURE_FEDERATED_TOKEN_FILE}' is empty.`, + }); + } + } catch { + ctx.addIssue({ + code: zod.ZodIssueCode.custom, + path: ['AZURE_FEDERATED_TOKEN_FILE'], + message: `Cannot read federated token file at path '${data.AZURE_FEDERATED_TOKEN_FILE}'. Ensure the file exists and is readable.`, + }); + } + + const ids = data.OIDC_WORKLOAD_FEDERATION_ORGANIZATION_IDS.split(',').map(s => s.trim()); + const invalid = ids.filter(id => !isUUID(id)); + if (invalid.length > 0) { + ctx.addIssue({ + code: zod.ZodIssueCode.custom, + path: ['OIDC_WORKLOAD_FEDERATION_ORGANIZATION_IDS'], + message: `The following values are not valid UUIDs: ${invalid.join(', ')}`, + }); + } + }), +]); + const processEnv = process.env; const configs = { @@ -328,6 +374,7 @@ const configs = { zendeskSupport: ZendeskSupportModel.safeParse(processEnv), tracing: OpenTelemetryConfigurationModel.safeParse(processEnv), hivePersistedDocuments: HivePersistedDocumentsSchema.safeParse(processEnv), + oidcWorkloadFederation: OidcWorkloadFederationModel.safeParse(processEnv), }; const environmentErrors: Array = []; @@ -427,6 +474,7 @@ const s3AuditLog = extractConfig(configs.s3AuditLog); const zendeskSupport = extractConfig(configs.zendeskSupport); const tracing = extractConfig(configs.tracing); const hivePersistedDocuments = extractConfig(configs.hivePersistedDocuments); +const oidcWorkloadFederation = extractConfig(configs.oidcWorkloadFederation); const testUtils = extractConfig(configs.testUtils); const hiveUsageConfig = @@ -656,4 +704,14 @@ export const env = { /** Whether metric alert rules should be enabled cluster-wide. */ metricAlertRulesEnabled: base.FEATURE_FLAGS_METRIC_ALERT_RULES_ENABLED === '1', }, + oidcWorkloadFederation: + oidcWorkloadFederation.OIDC_WORKLOAD_FEDERATION_IDENTITY_PROVIDER === 'azure' + ? { + provider: 'azure' as const, + tokenFilePath: oidcWorkloadFederation.AZURE_FEDERATED_TOKEN_FILE, + organizationIds: oidcWorkloadFederation.OIDC_WORKLOAD_FEDERATION_ORGANIZATION_IDS.split( + ',', + ).map(s => s.trim()), + } + : null, } as const; diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index dda94e6e08..2132d4a12b 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -57,6 +57,7 @@ import { clickHouseElapsedDuration, clickHouseReadDuration } from './metrics'; import { createOtelAuthEndpoint } from './otel-auth-endpoint'; import { createPublicGraphQLHandler } from './public-graphql-handler'; import { registerSupertokensAtHome } from './supertokens-at-home'; +import { WorkloadIdentityFederationProvider } from './workload-identity-federation'; class CorsError extends Error { constructor() { @@ -172,6 +173,17 @@ export async function main() { ); const taskScheduler = new TaskScheduler(storage.pool); + const workloadIdentityFederation = env.oidcWorkloadFederation + ? new WorkloadIdentityFederationProvider( + env.oidcWorkloadFederation.tokenFilePath, + server.log.child({ source: 'WorkloadIdentityFederation' }), + ) + : null; + + if (workloadIdentityFederation) { + await workloadIdentityFederation.start(); + } + const redis = await createRedisClient(env.redis, { logger: server.log.child({ source: 'Redis' }), }); @@ -194,6 +206,7 @@ export async function main() { await server.close(); server.log.info('Stopping Storage handler...'); await storage.destroy(); + workloadIdentityFederation?.stop(); server.log.info('Shutdown complete.'); }, }); @@ -541,6 +554,7 @@ export async function main() { registry.injector.get(OAuthCache), broadcastLog, env.supertokens.secrets, + workloadIdentityFederation, ); if (env.cdn.providers.api !== null) { diff --git a/packages/services/server/src/supertokens-at-home.ts b/packages/services/server/src/supertokens-at-home.ts index 55c847fbe2..7412d09fa4 100644 --- a/packages/services/server/src/supertokens-at-home.ts +++ b/packages/services/server/src/supertokens-at-home.ts @@ -25,6 +25,7 @@ import { TaskScheduler } from '@hive/workflows/kit'; import { PasswordResetTask } from '@hive/workflows/tasks/password-reset'; import { env } from './environment'; import { createNewSession, validatePassword } from './supertokens-at-home/shared'; +import type { WorkloadIdentityFederationProvider } from './workload-identity-federation'; type BroadcastOIDCIntegrationLog = (oidcOrganizationId: string, message: string) => void; @@ -43,6 +44,7 @@ export async function registerSupertokensAtHome( refreshTokenKey: string; accessTokenKey: string; }, + workloadIdentityFederation: WorkloadIdentityFederationProvider | null, ) { const supertokensStore = new SuperTokensStore(storage.pool, server.log); @@ -792,6 +794,13 @@ export async function registerSupertokensAtHome( const scopes = ['openid', 'email', ...oidcIntegration.additionalScopes]; + const useFederation = + workloadIdentityFederation !== null && + (env.oidcWorkloadFederation?.organizationIds.includes( + oidcIntegration.linkedOrganizationId, + ) ?? + false); + const oidClientConfig = new oidClient.Configuration( { issuer: 'noop', @@ -800,9 +809,11 @@ export async function registerSupertokensAtHome( token_endpoint: oidcIntegration.tokenEndpoint, }, oidcIntegration.clientId, - { - client_secret: crypto.decrypt(oidcIntegration.encryptedClientSecret), - }, + useFederation + ? undefined + : { + client_secret: crypto.decrypt(oidcIntegration.encryptedClientSecret), + }, ); oidClient.allowInsecureRequests(oidClientConfig); @@ -1304,20 +1315,48 @@ export async function registerSupertokensAtHome( access_token: z.string(), }); + const useFederationForExchange = + workloadIdentityFederation !== null && + (env.oidcWorkloadFederation?.organizationIds.includes( + oidcIntegration.linkedOrganizationId, + ) ?? + false); + + const grantParams: Record = { + grant_type: 'authorization_code', + code_verifier: cacheRecord.pkceVerifier, + code: parsedBody.data.redirectURIInfo.redirectURIQueryParams.code ?? '', + redirect_uri: current_url.toString(), + client_id: oidcIntegration.clientId, + }; + + if (useFederationForExchange) { + // Workload Identity Federation: use client_assertion with the cached federated token + const federatedToken = workloadIdentityFederation?.getToken() ?? null; + if (!federatedToken) { + req.log.error( + 'Workload identity federation is not configured or the token is not yet available', + ); + return rep.status(200).send({ + status: 'SIGN_IN_UP_NOT_ALLOWED', + reason: 'Sign in failed. Please contact your organization administrator.', + }); + } + + grantParams['client_assertion_type'] = + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; + grantParams['client_assertion'] = federatedToken; + } else { + grantParams['client_secret'] = crypto.decrypt(oidcIntegration.encryptedClientSecret); + } + const grantResponse = await fetch(oidcIntegration.tokenEndpoint, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json', }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - code_verifier: cacheRecord.pkceVerifier, - code: parsedBody.data.redirectURIInfo.redirectURIQueryParams.code ?? '', - redirect_uri: current_url.toString(), - client_id: oidcIntegration.clientId, - client_secret: crypto.decrypt(oidcIntegration.encryptedClientSecret), - }), + body: new URLSearchParams(grantParams), }); if (grantResponse.status != 200) { diff --git a/packages/services/server/src/workload-identity-federation.ts b/packages/services/server/src/workload-identity-federation.ts new file mode 100644 index 0000000000..40311547d1 --- /dev/null +++ b/packages/services/server/src/workload-identity-federation.ts @@ -0,0 +1,57 @@ +import { readFile } from 'node:fs/promises'; +import type { FastifyBaseLogger } from 'fastify'; + +const DEFAULT_POLL_INTERVAL_MS = 60_000; + +/** + * Polls the workload identity federation token file at a regular interval and + * keeps the current token in memory so it can be used without a per-request + * file read. + * + * Supports Azure Workload Identity today; additional providers can be added in + * the future by extending the `provider` discriminant. + */ +export class WorkloadIdentityFederationProvider { + private currentToken: string | null = null; + private timer: ReturnType | null = null; + + constructor( + private readonly tokenFilePath: string, + private readonly logger: FastifyBaseLogger, + private readonly pollIntervalMs: number = DEFAULT_POLL_INTERVAL_MS, + ) {} + + /** Read the token file once and start the polling interval. */ + async start(): Promise { + await this.refresh(); + this.timer = setInterval(() => { + void this.refresh(); + }, this.pollIntervalMs); + // Prevent the interval from keeping the process alive on its own. + this.timer.unref(); + } + + stop(): void { + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + } + + /** Returns the most recently cached token, or null if not yet loaded. */ + getToken(): string | null { + return this.currentToken; + } + + private async refresh(): Promise { + try { + this.currentToken = (await readFile(this.tokenFilePath, 'utf-8')).trim(); + } catch (err) { + this.logger.error( + { err }, + 'Failed to read workload identity federation token file (path=%s)', + this.tokenFilePath, + ); + } + } +}