Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
d94ee0c
feat: add Azure Federated Workload Identity support for OIDC SSO logi…
Copilot Apr 10, 2026
c744780
Update supertokens-at-home.ts
cosmincatalin Apr 11, 2026
74c80b3
fix: define AZURE_FEDERATED_TOKEN_FILE in environment.ts instead of d…
Copilot Apr 13, 2026
5f3b49d
Validate auth method invariant in updateOIDCIntegration (#4)
Copilot Apr 13, 2026
a7ee968
Merge branch 'main' into main
cosmincatalin Apr 13, 2026
2c41ae9
Merge branch 'main' into main
cosmincatalin Apr 13, 2026
2ce644d
Merge branch 'main' into main
cosmincatalin Apr 16, 2026
7243d4c
Merge branch 'graphql-hive:main' into main
cosmincatalin Apr 27, 2026
10b129c
Merge branch 'main' into main
cosmincatalin May 4, 2026
bebbbb6
refactor(oidc): introduce WorkloadIdentityFederationProvider for fede…
Copilot May 7, 2026
8e3a4b7
Merge branch 'graphql-hive:main' into main
cosmincatalin May 7, 2026
4012bda
Merge branch 'main' into main
cosmincatalin May 11, 2026
a839eef
Merge branch 'main' into main
cosmincatalin May 12, 2026
ae485c9
Merge branch 'main' into main
cosmincatalin May 12, 2026
822a06c
Merge branch 'main' into main
cosmincatalin May 14, 2026
056ca8a
Merge branch 'main' into main
cosmincatalin May 16, 2026
4fc31e2
Merge branch 'main' into main
cosmincatalin May 24, 2026
261b9a2
Merge branch 'main' into main
cosmincatalin May 26, 2026
c1d0902
Merge branch 'main' into main
cosmincatalin May 27, 2026
23144f9
Merge branch 'main' into main
cosmincatalin May 28, 2026
6531747
Merge branch 'main' into main
cosmincatalin May 28, 2026
ebd960d
Merge branch 'main' into main
cosmincatalin May 29, 2026
927bbcb
Merge branch 'main' into main
cosmincatalin May 29, 2026
5821e14
Merge branch 'main' into main
cosmincatalin Jun 1, 2026
9658bef
Merge branch 'main' into main
cosmincatalin Jun 2, 2026
59203b9
Merge branch 'main' into main
cosmincatalin Jun 2, 2026
d786c66
Merge branch 'main' into main
cosmincatalin Jun 3, 2026
9fb0159
Merge branch 'main' into main
cosmincatalin Jun 4, 2026
19216e6
Merge branch 'main' into main
cosmincatalin Jun 5, 2026
69b3c8f
Merge branch 'main' into main
cosmincatalin Jun 6, 2026
9b224fc
Merge branch 'main' into main
cosmincatalin Jun 8, 2026
2da0559
Merge branch 'main' into main
cosmincatalin Jun 8, 2026
02f9043
Merge branch 'main' into main
cosmincatalin Jun 9, 2026
4ae5242
Merge branch 'main' into main
cosmincatalin Jun 11, 2026
0ea3a4b
Merge branch 'main' into main
cosmincatalin Jun 14, 2026
a84ddec
Merge branch 'main' into main
cosmincatalin Jun 16, 2026
a7059b6
Merge branch 'main' into main
cosmincatalin Jun 17, 2026
6c68453
Merge branch 'main' into main
cosmincatalin Jun 18, 2026
53995c4
Merge branch 'main' into main
cosmincatalin Jun 24, 2026
0768e1b
Merge branch 'main' into main
cosmincatalin Jun 25, 2026
a1c383b
Merge branch 'main' into main
cosmincatalin Jun 28, 2026
9277d2e
Merge branch 'main' into main
cosmincatalin Jul 6, 2026
1cad297
Merge branch 'main' into main
cosmincatalin Jul 8, 2026
08a0be6
Merge branch 'main' into main
cosmincatalin Jul 10, 2026
496c797
Merge branch 'main' into main
cosmincatalin Jul 13, 2026
568e8f9
Merge branch 'main' into main
cosmincatalin Jul 17, 2026
e192777
Merge branch 'main' into main
cosmincatalin Jul 17, 2026
bd95b07
Merge branch 'main' into main
cosmincatalin Jul 20, 2026
1c8cbef
Merge branch 'graphql-hive:main' into main
cosmincatalin Jul 20, 2026
c4c732c
Remove OIDC federated credential flag from integrations
cosmincatalin Jul 20, 2026
a53de65
Improve readability of OIDC federation condition
cosmincatalin Jul 20, 2026
7ae1bb5
Replace local UUID regex with shared `isUUID` utility
cosmincatalin Jul 20, 2026
441cb9c
Merge branch 'main' into main
cosmincatalin Jul 21, 2026
6e3b3ed
Merge branch 'main' into main
cosmincatalin Jul 21, 2026
a0c8aa4
Merge branch 'main' into main
cosmincatalin Jul 23, 2026
9109a42
Merge branch 'main' into main
cosmincatalin Jul 23, 2026
1d01d74
Merge branch 'main' into main
cosmincatalin Jul 24, 2026
42eaf62
Merge branch 'main' into main
cosmincatalin Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/services/server/src/environment.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { readFileSync } from 'node:fs';
import zod from 'zod';
import { isUUID } from '@hive/api/shared/is-uuid';
import {
OpenTelemetryConfigurationModel,
parseRedisConfigFromEnvironment,
Expand Down Expand Up @@ -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 = {
Expand All @@ -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<string> = [];
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
14 changes: 14 additions & 0 deletions packages/services/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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' }),
});
Expand All @@ -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.');
},
});
Expand Down Expand Up @@ -541,6 +554,7 @@ export async function main() {
registry.injector.get(OAuthCache),
broadcastLog,
env.supertokens.secrets,
workloadIdentityFederation,
);

if (env.cdn.providers.api !== null) {
Expand Down
61 changes: 50 additions & 11 deletions packages/services/server/src/supertokens-at-home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -43,6 +44,7 @@ export async function registerSupertokensAtHome(
refreshTokenKey: string;
accessTokenKey: string;
},
workloadIdentityFederation: WorkloadIdentityFederationProvider | null,
) {
const supertokensStore = new SuperTokensStore(storage.pool, server.log);

Expand Down Expand Up @@ -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',
Expand All @@ -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);

Expand Down Expand Up @@ -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<string, string> = {
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) {
Expand Down
57 changes: 57 additions & 0 deletions packages/services/server/src/workload-identity-federation.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setInterval> | 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<void> {
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<void> {
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,
);
}
}
}
Loading