Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
#GITHUB_CLIENT_ID=<Make your own client>
#GITHUB_CLIENT_SECRET=<Make your own client>

# Generic OpenID Connect login (configure only one AUTH_OIDC_PROVIDER value).
# Microsoft Entra ID, single tenant. Redirect URI: <console origin>/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/<tenant-id>/v2.0","clientId":"<Application (client) ID>","clientSecret":"<Client secret value>"}'
# 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
Expand Down
85 changes: 85 additions & 0 deletions webapps/console/__tests__/unit/oidc.test.ts
Original file line number Diff line number Diff line change
@@ -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<OIDCProfile> = {
...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();
});
});
4 changes: 2 additions & 2 deletions webapps/console/lib/nextauth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
18 changes: 10 additions & 8 deletions webapps/console/lib/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { ApiError } from "./shared/errors";

export interface OIDCProfile extends Record<string, any> {
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<P> = OAuthUserConfig<P> & Required<Pick<OAuthConfig<P>, "issuer">>;
Expand Down Expand Up @@ -41,7 +41,7 @@ export function OIDCProvider<P extends OIDCProfile>(options: OIDCConfig<P>): 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"],
Expand All @@ -50,6 +50,8 @@ export function OIDCProvider<P extends OIDCProfile>(options: OIDCConfig<P>): 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,
};
Expand All @@ -58,9 +60,9 @@ export function OIDCProvider<P extends OIDCProfile>(options: OIDCConfig<P>): OAu
};
}

export function ParseJSONConfigFromEnv<P extends OIDCProfile>(env: string): OIDCConfig<P> | undefined {
export function ParseJSONConfigFromEnv<P extends OIDCProfile>(env?: string): OIDCConfig<P> | undefined {
try {
return env && env != '""' ? (JSON.parse(env) as OIDCConfig<P>) : undefined;
return env && env !== '""' ? (JSON.parse(env) as OIDCConfig<P>) : undefined;
} catch (error: unknown) {
console.error("Failed to parse JSON config from env", error);
return undefined;
Expand Down