From e52d632bb6b6035d9c017c70caec67cf801fbdf8 Mon Sep 17 00:00:00 2001 From: uditDewan Date: Tue, 14 Jul 2026 21:56:34 -0400 Subject: [PATCH] feat(console): support Entra ID through generic OIDC --- .env.example | 7 ++ webapps/console/__tests__/unit/oidc.test.ts | 85 +++++++++++++++++++++ webapps/console/lib/nextauth.config.ts | 4 +- webapps/console/lib/oidc.ts | 18 +++-- 4 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 webapps/console/__tests__/unit/oidc.test.ts diff --git a/.env.example b/.env.example index bdacbe671..20ec7637d 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,13 @@ #GITHUB_CLIENT_ID= #GITHUB_CLIENT_SECRET= +# Generic OpenID Connect login (configure only one AUTH_OIDC_PROVIDER value). +# Microsoft Entra ID, single tenant. Redirect URI: /api/auth/callback/oidc +# Omit the trailing slash from the issuer, and ensure the Entra app returns a usable email claim. +#AUTH_OIDC_PROVIDER='{"issuer":"https://login.microsoftonline.com//v2.0","clientId":"","clientSecret":""}' +# For multi-tenant Entra login, use https://login.microsoftonline.com/organizations/v2.0 +# for work/school accounts, or https://login.microsoftonline.com/common/v2.0 for work/school and personal accounts. +# Alternative non-Entra OIDC example (use instead of the Entra value above): #AUTH_OIDC_PROVIDER='{"issuer":"http://localhost:8080/realms/dev_realm","clientId":"dev_client","clientSecret":"your_generated_secret"}' #DATABASE_URL=postgresql://postgres:postgres-mqf3nzx@localhost:5438/postgres diff --git a/webapps/console/__tests__/unit/oidc.test.ts b/webapps/console/__tests__/unit/oidc.test.ts new file mode 100644 index 000000000..23853a9b9 --- /dev/null +++ b/webapps/console/__tests__/unit/oidc.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { OIDCProvider, ParseJSONConfigFromEnv } from "../../lib/oidc"; +import type { OIDCConfig, OIDCProfile } from "../../lib/oidc"; + +const clientConfig = { + clientId: "entra-client-id", + clientSecret: "entra-client-secret", +}; + +describe("OIDCProvider", () => { + it.each([ + { + name: "tenant-specific", + issuer: "https://login.microsoftonline.com/11111111-2222-3333-4444-555555555555/v2.0", + discovery: + "https://login.microsoftonline.com/11111111-2222-3333-4444-555555555555/v2.0/.well-known/openid-configuration", + }, + { + name: "common", + issuer: "https://login.microsoftonline.com/common/v2.0/", + discovery: "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration", + }, + ])("configures an Entra $name issuer through generic OIDC", ({ issuer, discovery }) => { + const provider = OIDCProvider({ ...clientConfig, issuer }); + + expect(provider.id).toBe("oidc"); + expect(provider.wellKnown).toBe(discovery); + expect(provider.authorization).toEqual({ params: { scope: "openid email profile" } }); + expect(provider.checks).toEqual(["pkce", "state"]); + }); + + it("maps an explicit email claim", async () => { + const provider = OIDCProvider({ + ...clientConfig, + issuer: "https://login.microsoftonline.com/common/v2.0", + }); + const profile: OIDCProfile = { + sub: "entra-user-id", + name: "Ada Lovelace", + preferred_username: "ada@example.com", + email: "verified@example.com", + picture: "https://example.com/avatar.png", + }; + + expect(await provider.profile(profile, {} as never)).toEqual({ + id: "entra-user-id", + name: "Ada Lovelace", + email: "verified@example.com", + image: "https://example.com/avatar.png", + }); + }); + + it("does not treat preferred_username as an email claim", async () => { + const provider = OIDCProvider({ + ...clientConfig, + issuer: "https://login.microsoftonline.com/common/v2.0", + }); + const profile: OIDCProfile = { + sub: "entra-user-id", + preferred_username: "ada@example.com", + }; + + expect(await provider.profile(profile, {} as never)).toEqual({ + id: "entra-user-id", + name: "ada@example.com", + email: undefined, + image: undefined, + }); + }); +}); + +describe("ParseJSONConfigFromEnv", () => { + it("parses an Entra issuer as a generic OIDC config", () => { + const config: OIDCConfig = { + ...clientConfig, + issuer: "https://login.microsoftonline.com/common/v2.0", + }; + + expect(ParseJSONConfigFromEnv(JSON.stringify(config))).toEqual(config); + }); + + it.each([undefined, "", '""', "not-json"])("does not enable OIDC for an unusable value", value => { + expect(ParseJSONConfigFromEnv(value)).toBeUndefined(); + }); +}); diff --git a/webapps/console/lib/nextauth.config.ts b/webapps/console/lib/nextauth.config.ts index 91378a711..a55c8c2f7 100644 --- a/webapps/console/lib/nextauth.config.ts +++ b/webapps/console/lib/nextauth.config.ts @@ -19,8 +19,8 @@ const log = getServerLog("auth"); const serverEnv = getServerEnv(); export const githubLoginEnabled = !!serverEnv.GITHUB_CLIENT_ID; -export const oidcLoginEnabled = !!serverEnv.AUTH_OIDC_PROVIDER; -export const oidcLoginConfig = ParseJSONConfigFromEnv(serverEnv.AUTH_OIDC_PROVIDER as string); +export const oidcLoginConfig = ParseJSONConfigFromEnv(serverEnv.AUTH_OIDC_PROVIDER); +export const oidcLoginEnabled = !!oidcLoginConfig; export const credentialsLoginEnabled = serverEnv.ENABLE_CREDENTIALS_LOGIN || !!(serverEnv.SEED_USER_EMAIL && serverEnv.SEED_USER_PASSWORD); diff --git a/webapps/console/lib/oidc.ts b/webapps/console/lib/oidc.ts index 127933e79..3c05e9bd6 100644 --- a/webapps/console/lib/oidc.ts +++ b/webapps/console/lib/oidc.ts @@ -3,11 +3,11 @@ import { ApiError } from "./shared/errors"; export interface OIDCProfile extends Record { sub: string; - name: string; - preferred_username: string; - nickname: string; - email: string; - picture: string; + name?: string | null; + preferred_username?: string | null; + nickname?: string | null; + email?: string | null; + picture?: string | null; } export type OIDCConfig

= OAuthUserConfig

& Required, "issuer">>; @@ -41,7 +41,7 @@ export function OIDCProvider

(options: OIDCConfig

): OAu return { id: "oidc", name: "OIDC", - wellKnown: `${options.issuer}/.well-known/openid-configuration`, + wellKnown: `${options.issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`, type: "oauth", authorization: { params: { scope: "openid email profile" } }, checks: ["pkce", "state"], @@ -50,6 +50,8 @@ export function OIDCProvider

(options: OIDCConfig

): OAu return { id: profile.sub, name: profile.name ?? profile.preferred_username ?? profile.nickname, + // Do not fall back to preferred_username: it is mutable and is not + // guaranteed to be an email address, while Jitsu uses email for invitations. email: profile.email, image: profile.picture, }; @@ -58,9 +60,9 @@ export function OIDCProvider

(options: OIDCConfig

): OAu }; } -export function ParseJSONConfigFromEnv

(env: string): OIDCConfig

| undefined { +export function ParseJSONConfigFromEnv

(env?: string): OIDCConfig

| undefined { try { - return env && env != '""' ? (JSON.parse(env) as OIDCConfig

) : undefined; + return env && env !== '""' ? (JSON.parse(env) as OIDCConfig

) : undefined; } catch (error: unknown) { console.error("Failed to parse JSON config from env", error); return undefined;