Skip to content

Commit ca2eb63

Browse files
committed
feat(auth): add generic OIDC and OAuth 2.0 plugins
1 parent 99de8b1 commit ca2eb63

File tree

7 files changed

+417
-3
lines changed

7 files changed

+417
-3
lines changed

.env.example

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,51 @@ CRON_SECRET="your-secret-key-here"
6363
# FAL_VIDEO_MODELS="fal-ai/veo3,fal-ai/kling-video/v2/master/text-to-video" # Comma-separated list of video models
6464
# FAL_IMAGE_MODELS="fal-ai/flux-pro/v1.1-ultra,fal-ai/flux/dev" # Comma-separated list of image models
6565

66-
# SENTRY_AUTH_TOKEN=sentry-auth-token
66+
# SENTRY_AUTH_TOKEN=sentry-auth-token
67+
68+
# ==============================================================================
69+
# SSO Authentication Configurations
70+
#
71+
# Why two generic providers (OIDC vs OAuth 2.0)?
72+
# - "OIDC" (OpenID Connect) performs strict cryptographic validation on the
73+
# ID Token (verifying 'aud' matching the client ID, 'iss' matching issuer, etc).
74+
# Use this for standard providers like Google, Auth0, Okta, Keycloak.
75+
# - "OAuth 2.0" bypasses strict OIDC validation. Use this for legacy or
76+
# enterprise systems that do not return OIDC ID Tokens
77+
# ==============================================================================
78+
79+
# Generic OIDC Config (Strict Validation)
80+
# ⚠️ Ensure you add "oidc" to the `providers` array in your prompts.config.ts file
81+
# Callback URL to whitelist: <your-domain>/api/auth/callback/oidc
82+
# AUTH_OIDC_ID="dummy-oidc-client-id"
83+
# AUTH_OIDC_SECRET="dummy-oidc-client-secret"
84+
# AUTH_OIDC_ISSUER="https://oidc.example.com"
85+
# AUTH_OIDC_WELLKNOWN="https://oidc.example.com/.well-known/openid-configuration"
86+
# AUTH_OIDC_SCOPE="openid email profile"
87+
# AUTH_OIDC_NAME="Company OIDC"
88+
# Optional overrides (uncomment to use):
89+
# AUTH_OIDC_LOGO="https://your-domain.com/oidc-logo.png" # Local path or full URL to button image
90+
# AUTH_OIDC_AUTHORIZATION_URL="https://oidc.example.com/authorize"
91+
# AUTH_OIDC_TOKEN_URL="https://oidc.example.com/token"
92+
# AUTH_OIDC_USERINFO_URL="https://oidc.example.com/userinfo"
93+
# AUTH_OIDC_JWKS_URL="https://oidc.example.com/jwks"
94+
# AUTH_OIDC_TOKEN_AUTH_METHOD="client_secret_post" # Allowed values: "client_secret_basic", "client_secret_post", "none"
95+
# AUTH_OIDC_ENABLE_PKCE="true" # PKCE is enabled by default. Set to "false" to disable.
96+
97+
# Generic OAuth 2.0 Config (Loose Validation)
98+
# ⚠️ Ensure you add "oauth" to the `providers` array in your prompts.config.ts file
99+
# Callback URL to whitelist: <your-domain>/api/auth/callback/oauth
100+
# AUTH_OAUTH_ID="dummy-oauth-client-id"
101+
# AUTH_OAUTH_SECRET="dummy-oauth-client-secret"
102+
# AUTH_OAUTH_ISSUER="https://sso.example.com"
103+
# AUTH_OAUTH_WELLKNOWN="https://sso.example.com/.well-known/openid-configuration"
104+
# AUTH_OAUTH_SCOPE="email profile"
105+
# AUTH_OAUTH_NAME="Company SSO"
106+
# Optional overrides (uncomment to use):
107+
# AUTH_OAUTH_LOGO="https://your-domain.com/sso-logo.png" # Local path or full URL to button image
108+
# AUTH_OAUTH_AUTHORIZATION_URL="https://sso.example.com/authorize"
109+
# AUTH_OAUTH_TOKEN_URL="https://sso.example.com/token"
110+
# AUTH_OAUTH_USERINFO_URL="https://sso.example.com/userinfo"
111+
# AUTH_OAUTH_JWKS_URL="https://sso.example.com/jwks"
112+
# AUTH_OAUTH_TOKEN_AUTH_METHOD="client_secret_basic" # Allowed values: "client_secret_basic", "client_secret_post", "none"
113+
# AUTH_OAUTH_ENABLE_PKCE="true" # PKCE is enabled by default. Set to "false" to disable.

prompts.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export default defineConfig({
3333

3434
// Authentication plugins
3535
auth: {
36-
// Available: "credentials" | "google" | "azure" | "github" | "apple" | custom
36+
// Available: "credentials" | "google" | "azure" | "github" | "apple" | "oidc" | "oauth" | custom
3737
// Use `providers` array to enable multiple auth providers
3838
providers: ["github", "google", "apple"],
3939
// Allow public registration (only applies to credentials provider)
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2+
import { oauthPlugin } from "@/lib/plugins/auth/oauth";
3+
4+
describe("OAuth 2.0 Auth Plugin", () => {
5+
const originalEnv = process.env;
6+
7+
beforeEach(() => {
8+
process.env = { ...originalEnv };
9+
});
10+
11+
afterEach(() => {
12+
process.env = originalEnv;
13+
});
14+
15+
it("should have correct plugin id and name", () => {
16+
expect(oauthPlugin.id).toBe("oauth");
17+
expect(oauthPlugin.name).toBe("Generic OAuth 2.0");
18+
});
19+
20+
it("should have PKCE enabled by default", () => {
21+
const provider: any = oauthPlugin.getProvider();
22+
expect(provider.checks).toBeUndefined(); // Enabled by default
23+
});
24+
25+
it("should allow disabling PKCE explicitly", () => {
26+
process.env.AUTH_OAUTH_ENABLE_PKCE = "false";
27+
const provider: any = oauthPlugin.getProvider();
28+
expect(provider.checks).toEqual(["state"]);
29+
});
30+
31+
it("should configure standard OAuth provider dynamically from env", () => {
32+
process.env.AUTH_OAUTH_ID = "test-client-id";
33+
process.env.AUTH_OAUTH_SECRET = "test-client-secret";
34+
process.env.AUTH_OAUTH_ISSUER = "https://sso.test.com";
35+
36+
const provider: any = oauthPlugin.getProvider();
37+
38+
expect(provider.id).toBe("oauth");
39+
expect(provider.type).toBe("oauth");
40+
expect(provider.clientId).toBe("test-client-id");
41+
expect(provider.clientSecret).toBe("test-client-secret");
42+
expect(provider.issuer).toBe("https://sso.test.com");
43+
44+
// Check fallback URLs derived from issuer
45+
expect(provider.authorization.url).toBe("https://sso.test.com/authorize");
46+
expect(provider.token).toBe("https://sso.test.com/token");
47+
expect(provider.userinfo).toBe("https://sso.test.com/userinfo");
48+
49+
// Check default scope (should NOT include openid for relaxed OAuth)
50+
expect(provider.authorization.params.scope).toBe("email profile");
51+
});
52+
53+
it("should use explicit URL overrides when provided", () => {
54+
process.env.AUTH_OAUTH_ID = "test-client-id";
55+
process.env.AUTH_OAUTH_SECRET = "test-client-secret";
56+
process.env.AUTH_OAUTH_ISSUER = "https://sso.test.com";
57+
58+
process.env.AUTH_OAUTH_AUTHORIZATION_URL = "https://custom.test.com/auth";
59+
process.env.AUTH_OAUTH_TOKEN_URL = "https://custom.test.com/token";
60+
process.env.AUTH_OAUTH_USERINFO_URL = "https://custom.test.com/me";
61+
62+
const provider: any = oauthPlugin.getProvider();
63+
64+
expect(provider.authorization.url).toBe("https://custom.test.com/auth");
65+
expect(provider.token).toBe("https://custom.test.com/token");
66+
expect(provider.userinfo).toBe("https://custom.test.com/me");
67+
});
68+
69+
it("should handle wellknown discovery correctly", () => {
70+
process.env.AUTH_OAUTH_ID = "test-client-id";
71+
process.env.AUTH_OAUTH_SECRET = "test-client-secret";
72+
process.env.AUTH_OAUTH_ISSUER = "https://sso.test.com";
73+
process.env.AUTH_OAUTH_WELLKNOWN = "https://sso.test.com/.well-known";
74+
75+
const provider: any = oauthPlugin.getProvider();
76+
77+
expect(provider.wellKnown).toBe("https://sso.test.com/.well-known");
78+
expect(provider.authorization.url).toBeUndefined(); // Let wellKnown handle it
79+
expect(provider.token).toBeUndefined();
80+
expect(provider.userinfo).toBeUndefined();
81+
});
82+
83+
it("should handle custom style logo", () => {
84+
process.env.AUTH_OAUTH_LOGO = "https://logo.com/image.png";
85+
const provider: any = oauthPlugin.getProvider();
86+
87+
expect(provider.style?.logo).toBe("https://logo.com/image.png");
88+
});
89+
90+
it("should process user profile mappings correctly", () => {
91+
const provider: any = oauthPlugin.getProvider();
92+
93+
const mockProfile = {
94+
sub: "12345",
95+
name: "Test User",
96+
email: "test@example.com",
97+
picture: "https://avatar.com/me.png",
98+
preferred_username: "testuser"
99+
};
100+
101+
const parsedProfile = provider.profile(mockProfile);
102+
103+
expect(parsedProfile.id).toBe("12345");
104+
expect(parsedProfile.name).toBe("Test User");
105+
expect(parsedProfile.email).toBe("test@example.com");
106+
expect(parsedProfile.image).toBe("https://avatar.com/me.png");
107+
expect(parsedProfile.username).toBe("testuser");
108+
});
109+
110+
it("should fallback correctly if standard profile fields are missing", () => {
111+
const provider: any = oauthPlugin.getProvider();
112+
113+
const weirdProfile = {
114+
id: 999, // Testing numeric ID coercion
115+
email: "weird@example.com",
116+
avatar_url: "https://avatar.com/me2.png"
117+
};
118+
119+
const parsedProfile = provider.profile(weirdProfile);
120+
121+
expect(parsedProfile.id).toBe("999");
122+
expect(parsedProfile.name).toBe("weird@example.com"); // Fallback to email
123+
expect(parsedProfile.email).toBe("weird@example.com");
124+
expect(parsedProfile.image).toBe("https://avatar.com/me2.png");
125+
expect(parsedProfile.username).toBe("weird"); // Fallback to email prefix
126+
});
127+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2+
import { oidcPlugin } from "@/lib/plugins/auth/oidc";
3+
4+
describe("OpenID Connect Auth Plugin", () => {
5+
const originalEnv = process.env;
6+
7+
beforeEach(() => {
8+
process.env = { ...originalEnv };
9+
});
10+
11+
afterEach(() => {
12+
process.env = originalEnv;
13+
});
14+
15+
it("should have correct plugin id and name", () => {
16+
expect(oidcPlugin.id).toBe("oidc");
17+
expect(oidcPlugin.name).toBe("Generic OIDC");
18+
});
19+
20+
it("should configure standard OIDC provider dynamically from env", () => {
21+
process.env.AUTH_OIDC_ID = "test-client-id";
22+
process.env.AUTH_OIDC_SECRET = "test-client-secret";
23+
process.env.AUTH_OIDC_ISSUER = "https://sso.test.com";
24+
25+
const provider: any = oidcPlugin.getProvider();
26+
27+
expect(provider.id).toBe("oidc");
28+
expect(provider.type).toBe("oidc");
29+
expect(provider.clientId).toBe("test-client-id");
30+
expect(provider.clientSecret).toBe("test-client-secret");
31+
expect(provider.issuer).toBe("https://sso.test.com");
32+
expect(provider.wellKnown).toBe("https://sso.test.com/.well-known/openid-configuration");
33+
});
34+
35+
it("should support well-known discovery override", () => {
36+
process.env.AUTH_OIDC_ISSUER = "https://sso.test.com";
37+
process.env.AUTH_OIDC_WELLKNOWN = "https://sso.test.com/custom-well-known";
38+
39+
const provider: any = oidcPlugin.getProvider();
40+
expect(provider.wellKnown).toBe("https://sso.test.com/custom-well-known");
41+
});
42+
43+
it("should have PKCE enabled by default", () => {
44+
process.env.AUTH_OIDC_ENABLE_PKCE = undefined;
45+
const provider: any = oidcPlugin.getProvider();
46+
expect(provider.checks).toBeUndefined(); // Enabled by default
47+
});
48+
49+
it("should apply PKCE bypass if explicitly disabled", () => {
50+
process.env.AUTH_OIDC_ENABLE_PKCE = "false";
51+
const provider: any = oidcPlugin.getProvider();
52+
expect(provider.checks).toEqual(["state"]);
53+
});
54+
55+
it("should keep PKCE enabled if explicitly turned on", () => {
56+
process.env.AUTH_OIDC_ENABLE_PKCE = "true";
57+
const provider: any = oidcPlugin.getProvider();
58+
expect(provider.checks).toBeUndefined(); // Auth.js will handle PKCE natively
59+
});
60+
61+
it("should handle custom style logo", () => {
62+
process.env.AUTH_OIDC_LOGO = "https://logo.com/image.png";
63+
const provider: any = oidcPlugin.getProvider();
64+
65+
expect(provider.style?.logo).toBe("https://logo.com/image.png");
66+
});
67+
68+
it("should fall back to standard UserInfo fields correctly", () => {
69+
process.env.AUTH_OIDC_ID = "test-client-id";
70+
process.env.AUTH_OIDC_SECRET = "test-client-secret";
71+
process.env.AUTH_OIDC_ISSUER = "https://sso.test.com";
72+
73+
const provider: any = oidcPlugin.getProvider();
74+
75+
const mockProfile = {
76+
sub: "12345",
77+
name: "Test User",
78+
email: "test@example.com",
79+
picture: "https://avatar.com/me.png",
80+
preferred_username: "testuser"
81+
};
82+
83+
const parsedProfile: any = provider.profile(mockProfile);
84+
85+
expect(parsedProfile.id).toBe("12345");
86+
expect(parsedProfile.name).toBe("Test User");
87+
expect(parsedProfile.email).toBe("test@example.com");
88+
expect(parsedProfile.image).toBe("https://avatar.com/me.png");
89+
expect(parsedProfile.username).toBe("testuser");
90+
});
91+
});

src/lib/plugins/auth/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { googlePlugin } from "./google";
44
import { azurePlugin } from "./azure";
55
import { githubPlugin } from "./github";
66
import { applePlugin } from "./apple";
7+
import { oidcPlugin } from "./oidc";
8+
import { oauthPlugin } from "./oauth";
79

810
// Register all built-in auth plugins
911
export function registerBuiltInAuthPlugins(): void {
@@ -12,6 +14,8 @@ export function registerBuiltInAuthPlugins(): void {
1214
registerAuthPlugin(azurePlugin);
1315
registerAuthPlugin(githubPlugin);
1416
registerAuthPlugin(applePlugin);
17+
registerAuthPlugin(oidcPlugin);
18+
registerAuthPlugin(oauthPlugin);
1519
}
1620

17-
export { credentialsPlugin, googlePlugin, azurePlugin, githubPlugin, applePlugin };
21+
export { credentialsPlugin, googlePlugin, azurePlugin, githubPlugin, applePlugin, oidcPlugin, oauthPlugin };

src/lib/plugins/auth/oauth.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { OAuth2Config } from "next-auth/providers";
2+
import type { AuthPlugin } from "../types";
3+
4+
export interface GenericOAuthProfile extends Record<string, unknown> {
5+
sub?: string;
6+
id?: string;
7+
name?: string;
8+
preferred_username?: string;
9+
email?: string;
10+
picture?: string;
11+
avatar_url?: string;
12+
nickname?: string;
13+
}
14+
15+
export const oauthPlugin: AuthPlugin = {
16+
id: "oauth",
17+
name: "Generic OAuth 2.0",
18+
getProvider: () => {
19+
const tokenAuthMethod = process.env.AUTH_OAUTH_TOKEN_AUTH_METHOD;
20+
21+
const provider: OAuth2Config<GenericOAuthProfile> = {
22+
id: "oauth",
23+
name: process.env.AUTH_OAUTH_NAME!,
24+
type: "oauth",
25+
...(process.env.AUTH_OAUTH_LOGO ? { style: { logo: process.env.AUTH_OAUTH_LOGO } } : {}),
26+
clientId: process.env.AUTH_OAUTH_ID!,
27+
clientSecret: process.env.AUTH_OAUTH_SECRET!,
28+
issuer: process.env.AUTH_OAUTH_ISSUER!,
29+
...(process.env.AUTH_OAUTH_WELLKNOWN ? { wellKnown: process.env.AUTH_OAUTH_WELLKNOWN } : {}),
30+
authorization: {
31+
url: process.env.AUTH_OAUTH_AUTHORIZATION_URL || (process.env.AUTH_OAUTH_WELLKNOWN ? undefined : `${process.env.AUTH_OAUTH_ISSUER}/authorize`),
32+
// Default to empty scopes for relaxed OAuth as legacy providers often fail with OIDC-specific scopes like 'openid'
33+
params: { scope: process.env.AUTH_OAUTH_SCOPE || "email profile" }
34+
},
35+
client: {
36+
token_endpoint_auth_method:
37+
tokenAuthMethod === "client_secret_post" ? "client_secret_post" :
38+
tokenAuthMethod === "none" ? "none" :
39+
"client_secret_basic",
40+
},
41+
token: process.env.AUTH_OAUTH_TOKEN_URL || (process.env.AUTH_OAUTH_WELLKNOWN ? undefined : `${process.env.AUTH_OAUTH_ISSUER}/token`),
42+
userinfo: process.env.AUTH_OAUTH_USERINFO_URL || (process.env.AUTH_OAUTH_WELLKNOWN ? undefined : `${process.env.AUTH_OAUTH_ISSUER}/userinfo`),
43+
...(process.env.AUTH_OAUTH_JWKS_URL ? { jwks_endpoint: process.env.AUTH_OAUTH_JWKS_URL } : {}),
44+
// PKCE is enabled by default for OAuth. It must be explicitly set to "false" to be disabled.
45+
...(process.env.AUTH_OAUTH_ENABLE_PKCE === "false" ? { checks: ["state"] } : {}),
46+
profile(profile) {
47+
const id = profile.sub || profile.id;
48+
const email = profile.email;
49+
50+
// Validate required fields.
51+
// The downstream 'jwt' callback in src/lib/auth/index.ts depends on user.email
52+
// for database lookups. If missing, it fails silently.
53+
if (!id) {
54+
throw new Error("OAuth profile is missing a unique identifier (sub/id).");
55+
}
56+
if (!email || typeof email !== "string") {
57+
throw new Error("OAuth profile is missing a valid email address.");
58+
}
59+
60+
// Coerce identifier to string to satisfy type contracts
61+
const stringId = String(id);
62+
63+
return {
64+
id: stringId,
65+
name: profile.name || profile.preferred_username || email,
66+
email: email, // Required as per Session.user type and DB lookups
67+
image: profile.picture || profile.avatar_url || "",
68+
username: profile.preferred_username || profile.nickname || email.split("@")[0] || stringId,
69+
};
70+
},
71+
};
72+
return provider;
73+
},
74+
};

0 commit comments

Comments
 (0)