-
-
Notifications
You must be signed in to change notification settings - Fork 20.9k
feat(auth): add generic OIDC and OAuth 2.0 plugins #1056
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Amaya54
wants to merge
3
commits into
f:main
Choose a base branch
from
Amaya54:feat/sso-oauth-plugin
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| import { describe, it, expect, beforeEach, afterEach } from "vitest"; | ||
| import { oauthPlugin } from "@/lib/plugins/auth/oauth"; | ||
|
|
||
| describe("OAuth 2.0 Auth Plugin", () => { | ||
| const originalEnv = process.env; | ||
|
|
||
| beforeEach(() => { | ||
| process.env = { ...originalEnv }; | ||
| process.env.AUTH_OAUTH_ID = "test-client-id"; | ||
| process.env.AUTH_OAUTH_SECRET = "test-client-secret"; | ||
| process.env.AUTH_OAUTH_ISSUER = "https://sso.test.com"; | ||
| process.env.AUTH_OAUTH_NAME = "Test OAuth"; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| process.env = originalEnv; | ||
| }); | ||
|
|
||
| it("should have correct plugin id and name", () => { | ||
| expect(oauthPlugin.id).toBe("oauth"); | ||
| expect(oauthPlugin.name).toBe("Generic OAuth 2.0"); | ||
| }); | ||
|
|
||
| it("should have PKCE enabled by default", () => { | ||
| const provider: any = oauthPlugin.getProvider(); | ||
| expect(provider.checks).toBeUndefined(); // Enabled by default | ||
| }); | ||
|
|
||
| it("should allow disabling PKCE explicitly", () => { | ||
| process.env.AUTH_OAUTH_ENABLE_PKCE = "false"; | ||
| const provider: any = oauthPlugin.getProvider(); | ||
| expect(provider.checks).toEqual(["state"]); | ||
| }); | ||
|
|
||
| it("should configure standard OAuth provider dynamically from env", () => { | ||
| process.env.AUTH_OAUTH_ID = "test-client-id"; | ||
| process.env.AUTH_OAUTH_SECRET = "test-client-secret"; | ||
| process.env.AUTH_OAUTH_ISSUER = "https://sso.test.com"; | ||
|
|
||
| const provider: any = oauthPlugin.getProvider(); | ||
|
|
||
| expect(provider.id).toBe("oauth"); | ||
| expect(provider.type).toBe("oauth"); | ||
| expect(provider.clientId).toBe("test-client-id"); | ||
| expect(provider.clientSecret).toBe("test-client-secret"); | ||
| expect(provider.issuer).toBe("https://sso.test.com"); | ||
|
|
||
| // Check fallback URLs derived from issuer | ||
| expect(provider.authorization.url).toBe("https://sso.test.com/authorize"); | ||
| expect(provider.token).toBe("https://sso.test.com/token"); | ||
| expect(provider.userinfo).toBe("https://sso.test.com/userinfo"); | ||
|
|
||
| // Check default scope (should NOT include openid for relaxed OAuth) | ||
| expect(provider.authorization.params.scope).toBe("email profile"); | ||
| }); | ||
|
|
||
| it("should use explicit URL overrides when provided", () => { | ||
| process.env.AUTH_OAUTH_ID = "test-client-id"; | ||
| process.env.AUTH_OAUTH_SECRET = "test-client-secret"; | ||
| process.env.AUTH_OAUTH_ISSUER = "https://sso.test.com"; | ||
|
|
||
| process.env.AUTH_OAUTH_AUTHORIZATION_URL = "https://custom.test.com/auth"; | ||
| process.env.AUTH_OAUTH_TOKEN_URL = "https://custom.test.com/token"; | ||
| process.env.AUTH_OAUTH_USERINFO_URL = "https://custom.test.com/me"; | ||
|
|
||
| const provider: any = oauthPlugin.getProvider(); | ||
|
|
||
| expect(provider.authorization.url).toBe("https://custom.test.com/auth"); | ||
| expect(provider.token).toBe("https://custom.test.com/token"); | ||
| expect(provider.userinfo).toBe("https://custom.test.com/me"); | ||
| }); | ||
|
|
||
| it("should handle wellknown discovery correctly", () => { | ||
| process.env.AUTH_OAUTH_ID = "test-client-id"; | ||
| process.env.AUTH_OAUTH_SECRET = "test-client-secret"; | ||
| process.env.AUTH_OAUTH_ISSUER = "https://sso.test.com"; | ||
| process.env.AUTH_OAUTH_WELLKNOWN = "https://sso.test.com/.well-known"; | ||
|
|
||
| const provider: any = oauthPlugin.getProvider(); | ||
|
|
||
| expect(provider.wellKnown).toBe("https://sso.test.com/.well-known"); | ||
| expect(provider.authorization.url).toBeUndefined(); // Let wellKnown handle it | ||
| expect(provider.token).toBeUndefined(); | ||
| expect(provider.userinfo).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("should handle custom style logo", () => { | ||
| process.env.AUTH_OAUTH_LOGO = "https://logo.com/image.png"; | ||
| const provider: any = oauthPlugin.getProvider(); | ||
|
|
||
| expect(provider.style?.logo).toBe("https://logo.com/image.png"); | ||
| }); | ||
|
|
||
| it("should process user profile mappings correctly", () => { | ||
| const provider: any = oauthPlugin.getProvider(); | ||
|
|
||
| const mockProfile = { | ||
| sub: "12345", | ||
| name: "Test User", | ||
| email: "test@example.com", | ||
| picture: "https://avatar.com/me.png", | ||
| preferred_username: "testuser" | ||
| }; | ||
|
|
||
| const parsedProfile = provider.profile(mockProfile); | ||
|
|
||
| expect(parsedProfile.id).toBe("12345"); | ||
| expect(parsedProfile.name).toBe("Test User"); | ||
| expect(parsedProfile.email).toBe("test@example.com"); | ||
| expect(parsedProfile.image).toBe("https://avatar.com/me.png"); | ||
| expect(parsedProfile.username).toBe("testuser"); | ||
| }); | ||
|
|
||
| it("should fallback correctly if standard profile fields are missing", () => { | ||
| const provider: any = oauthPlugin.getProvider(); | ||
|
|
||
| const weirdProfile = { | ||
| id: 999, // Testing numeric ID coercion | ||
| email: "weird@example.com", | ||
| avatar_url: "https://avatar.com/me2.png" | ||
| }; | ||
|
|
||
| const parsedProfile = provider.profile(weirdProfile); | ||
|
|
||
| expect(parsedProfile.id).toBe("999"); | ||
| expect(parsedProfile.name).toBe("weird@example.com"); // Fallback to email | ||
| expect(parsedProfile.email).toBe("weird@example.com"); | ||
| expect(parsedProfile.image).toBe("https://avatar.com/me2.png"); | ||
| expect(parsedProfile.username).toBe("weird"); // Fallback to email prefix | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import { describe, it, expect, beforeEach, afterEach } from "vitest"; | ||
| import { oidcPlugin } from "@/lib/plugins/auth/oidc"; | ||
|
|
||
| describe("OpenID Connect Auth Plugin", () => { | ||
| const originalEnv = process.env; | ||
|
|
||
| beforeEach(() => { | ||
| process.env = { ...originalEnv }; | ||
| process.env.AUTH_OIDC_ID = "test-client-id"; | ||
| process.env.AUTH_OIDC_SECRET = "test-client-secret"; | ||
| process.env.AUTH_OIDC_ISSUER = "https://sso.test.com"; | ||
| process.env.AUTH_OIDC_NAME = "Test OIDC"; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| process.env = originalEnv; | ||
| }); | ||
|
|
||
| it("should have correct plugin id and name", () => { | ||
| expect(oidcPlugin.id).toBe("oidc"); | ||
| expect(oidcPlugin.name).toBe("Generic OIDC"); | ||
| }); | ||
|
|
||
| it("should configure standard OIDC provider dynamically from env", () => { | ||
| process.env.AUTH_OIDC_ID = "test-client-id"; | ||
| process.env.AUTH_OIDC_SECRET = "test-client-secret"; | ||
| process.env.AUTH_OIDC_ISSUER = "https://sso.test.com"; | ||
|
|
||
| const provider: any = oidcPlugin.getProvider(); | ||
|
|
||
| expect(provider.id).toBe("oidc"); | ||
| expect(provider.type).toBe("oidc"); | ||
| expect(provider.clientId).toBe("test-client-id"); | ||
| expect(provider.clientSecret).toBe("test-client-secret"); | ||
| expect(provider.issuer).toBe("https://sso.test.com"); | ||
| expect(provider.wellKnown).toBe("https://sso.test.com/.well-known/openid-configuration"); | ||
| }); | ||
|
|
||
| it("should support well-known discovery override", () => { | ||
| process.env.AUTH_OIDC_ISSUER = "https://sso.test.com"; | ||
| process.env.AUTH_OIDC_WELLKNOWN = "https://sso.test.com/custom-well-known"; | ||
|
|
||
| const provider: any = oidcPlugin.getProvider(); | ||
| expect(provider.wellKnown).toBe("https://sso.test.com/custom-well-known"); | ||
| }); | ||
|
|
||
| it("should have PKCE enabled by default", () => { | ||
| delete process.env.AUTH_OIDC_ENABLE_PKCE; | ||
| const provider: any = oidcPlugin.getProvider(); | ||
| expect(provider.checks).toBeUndefined(); // Enabled by default | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }); | ||
|
|
||
| it("should apply PKCE bypass if explicitly disabled", () => { | ||
| process.env.AUTH_OIDC_ENABLE_PKCE = "false"; | ||
| const provider: any = oidcPlugin.getProvider(); | ||
| expect(provider.checks).toEqual(["state"]); | ||
| }); | ||
|
|
||
| it("should keep PKCE enabled if explicitly turned on", () => { | ||
| process.env.AUTH_OIDC_ENABLE_PKCE = "true"; | ||
| const provider: any = oidcPlugin.getProvider(); | ||
| expect(provider.checks).toBeUndefined(); // Auth.js will handle PKCE natively | ||
| }); | ||
|
|
||
| it("should handle custom style logo", () => { | ||
| process.env.AUTH_OIDC_LOGO = "https://logo.com/image.png"; | ||
| const provider: any = oidcPlugin.getProvider(); | ||
|
|
||
| expect(provider.style?.logo).toBe("https://logo.com/image.png"); | ||
| }); | ||
|
|
||
| it("should fall back to standard UserInfo fields correctly", () => { | ||
| process.env.AUTH_OIDC_ID = "test-client-id"; | ||
| process.env.AUTH_OIDC_SECRET = "test-client-secret"; | ||
| process.env.AUTH_OIDC_ISSUER = "https://sso.test.com"; | ||
|
|
||
| const provider: any = oidcPlugin.getProvider(); | ||
|
|
||
| const mockProfile = { | ||
| sub: "12345", | ||
| name: "Test User", | ||
| email: "test@example.com", | ||
| picture: "https://avatar.com/me.png", | ||
| preferred_username: "testuser" | ||
| }; | ||
|
|
||
| const parsedProfile: any = provider.profile(mockProfile); | ||
|
|
||
| expect(parsedProfile.id).toBe("12345"); | ||
| expect(parsedProfile.name).toBe("Test User"); | ||
| expect(parsedProfile.email).toBe("test@example.com"); | ||
| expect(parsedProfile.image).toBe("https://avatar.com/me.png"); | ||
| expect(parsedProfile.username).toBe("testuser"); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| import type { OAuth2Config } from "next-auth/providers"; | ||
| import type { AuthPlugin } from "../types"; | ||
|
|
||
| export interface GenericOAuthProfile extends Record<string, unknown> { | ||
| sub?: string; | ||
| id?: string; | ||
| name?: string; | ||
| preferred_username?: string; | ||
| email?: string; | ||
| picture?: string; | ||
| avatar_url?: string; | ||
| nickname?: string; | ||
| } | ||
|
Amaya54 marked this conversation as resolved.
|
||
|
|
||
| export const oauthPlugin: AuthPlugin = { | ||
| id: "oauth", | ||
| name: "Generic OAuth 2.0", | ||
| getProvider: () => { | ||
| const tokenAuthMethod = process.env.AUTH_OAUTH_TOKEN_AUTH_METHOD; | ||
| const clientId = process.env.AUTH_OAUTH_ID; | ||
| const clientSecret = process.env.AUTH_OAUTH_SECRET; | ||
| const issuer = process.env.AUTH_OAUTH_ISSUER; | ||
| const name = process.env.AUTH_OAUTH_NAME; | ||
|
|
||
| if (!clientId || !issuer || !name) { | ||
| throw new Error("OAuth configuration is missing required environment variables: AUTH_OAUTH_ID, AUTH_OAUTH_ISSUER, or AUTH_OAUTH_NAME"); | ||
| } | ||
|
|
||
| if (!clientSecret && tokenAuthMethod !== "none") { | ||
| throw new Error("AUTH_OAUTH_SECRET is required unless AUTH_OAUTH_TOKEN_AUTH_METHOD is set to 'none'"); | ||
| } | ||
|
|
||
| const provider: OAuth2Config<GenericOAuthProfile> = { | ||
| id: "oauth", | ||
| name, | ||
| type: "oauth", | ||
| ...(process.env.AUTH_OAUTH_LOGO ? { style: { logo: process.env.AUTH_OAUTH_LOGO } } : {}), | ||
| clientId, | ||
| ...(clientSecret ? { clientSecret } : {}), | ||
| issuer, | ||
| ...(process.env.AUTH_OAUTH_WELLKNOWN ? { wellKnown: process.env.AUTH_OAUTH_WELLKNOWN } : {}), | ||
| authorization: { | ||
| url: process.env.AUTH_OAUTH_AUTHORIZATION_URL || (process.env.AUTH_OAUTH_WELLKNOWN ? undefined : `${issuer}/authorize`), | ||
| // Default to empty scopes for relaxed OAuth as legacy providers often fail with OIDC-specific scopes like 'openid' | ||
| params: { scope: process.env.AUTH_OAUTH_SCOPE || "email profile" } | ||
| }, | ||
| client: { | ||
| token_endpoint_auth_method: | ||
| tokenAuthMethod === "client_secret_post" ? "client_secret_post" : | ||
| tokenAuthMethod === "none" ? "none" : | ||
| "client_secret_basic", | ||
| }, | ||
| token: process.env.AUTH_OAUTH_TOKEN_URL || (process.env.AUTH_OAUTH_WELLKNOWN ? undefined : `${issuer}/token`), | ||
| userinfo: process.env.AUTH_OAUTH_USERINFO_URL || (process.env.AUTH_OAUTH_WELLKNOWN ? undefined : `${issuer}/userinfo`), | ||
| ...(process.env.AUTH_OAUTH_JWKS_URL ? { jwks_endpoint: process.env.AUTH_OAUTH_JWKS_URL } : {}), | ||
| // PKCE is enabled by default for OAuth. It must be explicitly set to "false" to be disabled. | ||
| ...(process.env.AUTH_OAUTH_ENABLE_PKCE === "false" ? { checks: ["state"] } : {}), | ||
| profile(profile) { | ||
| const id = profile.sub || profile.id; | ||
| const email = profile.email; | ||
|
|
||
| // Validate required fields. | ||
| // The downstream 'jwt' callback in src/lib/auth/index.ts depends on user.email | ||
| // for database lookups. If missing, it fails silently. | ||
| if (!id) { | ||
| throw new Error("OAuth profile is missing a unique identifier (sub/id)."); | ||
| } | ||
| if (!email || typeof email !== "string") { | ||
| throw new Error("OAuth profile is missing a valid email address."); | ||
| } | ||
|
|
||
| // Coerce identifier to string to satisfy type contracts | ||
| const stringId = String(id); | ||
|
|
||
| return { | ||
| id: stringId, | ||
| name: profile.name || profile.preferred_username || email, | ||
| email: email, // Required as per Session.user type and DB lookups | ||
| image: profile.picture || profile.avatar_url || "", | ||
| username: profile.preferred_username || profile.nickname || email.split("@")[0] || stringId, | ||
| }; | ||
| }, | ||
| }; | ||
| return provider; | ||
| }, | ||
| }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.