diff --git a/.env.example b/.env.example index 59507624..30579bce 100644 --- a/.env.example +++ b/.env.example @@ -65,4 +65,52 @@ CRON_SECRET="your-secret-key-here" # SENTRY_AUTH_TOKEN=sentry-auth-token -# GOOGLE_ADSENSE_ACCOUNT=ca-pub-xxxxxxxxxxxxxxxx \ No newline at end of file +# GOOGLE_ADSENSE_ACCOUNT=ca-pub-xxxxxxxxxxxxxxxx + +# ============================================================================== +# SSO Authentication Configurations +# +# Why two generic providers (OIDC vs OAuth 2.0)? +# - "OIDC" (OpenID Connect) performs strict cryptographic validation on the +# ID Token (verifying 'aud' matching the client ID, 'iss' matching issuer, etc). +# Use this for standard providers like Google, Auth0, Okta, Keycloak. +# - "OAuth 2.0" bypasses strict OIDC validation. Use this for legacy or +# enterprise systems that do not return OIDC ID Tokens +# ============================================================================== + +# Generic OIDC Config (Strict Validation) +# ⚠️ Ensure you add "oidc" to the `providers` array in your prompts.config.ts file +# Callback URL to whitelist: /api/auth/callback/oidc +# AUTH_OIDC_ID="dummy-oidc-client-id" +# AUTH_OIDC_SECRET="dummy-oidc-client-secret" +# AUTH_OIDC_ISSUER="https://oidc.example.com" +# AUTH_OIDC_WELLKNOWN="https://oidc.example.com/.well-known/openid-configuration" +# AUTH_OIDC_SCOPE="openid email profile" +# AUTH_OIDC_NAME="Company OIDC" +# Optional overrides (uncomment to use): +# AUTH_OIDC_LOGO="https://your-domain.com/oidc-logo.png" # Local path or full URL to button image +# AUTH_OIDC_AUTHORIZATION_URL="https://oidc.example.com/authorize" +# AUTH_OIDC_TOKEN_URL="https://oidc.example.com/token" +# AUTH_OIDC_USERINFO_URL="https://oidc.example.com/userinfo" +# AUTH_OIDC_JWKS_URL="https://oidc.example.com/jwks" +# AUTH_OIDC_TOKEN_AUTH_METHOD="client_secret_post" # Allowed values: "client_secret_basic", "client_secret_post", "none" +# AUTH_OIDC_ENABLE_PKCE="true" # PKCE is enabled by default. Set to "false" to disable. + +# Generic OAuth 2.0 Config (Loose Validation) +# ⚠️ Ensure you add "oauth" to the `providers` array in your prompts.config.ts file +# Callback URL to whitelist: /api/auth/callback/oauth +# AUTH_OAUTH_ID="dummy-oauth-client-id" +# AUTH_OAUTH_SECRET="dummy-oauth-client-secret" +# AUTH_OAUTH_ISSUER="https://sso.example.com" +# AUTH_OAUTH_WELLKNOWN="https://sso.example.com/.well-known/openid-configuration" +# AUTH_OAUTH_SCOPE="email profile" +# AUTH_OAUTH_NAME="Company SSO" +# Optional overrides (uncomment to use): +# AUTH_OAUTH_LOGO="https://your-domain.com/sso-logo.png" # Local path or full URL to button image +# AUTH_OAUTH_AUTHORIZATION_URL="https://sso.example.com/authorize" +# AUTH_OAUTH_TOKEN_URL="https://sso.example.com/token" +# AUTH_OAUTH_USERINFO_URL="https://sso.example.com/userinfo" +# AUTH_OAUTH_JWKS_URL="https://sso.example.com/jwks" +# AUTH_OAUTH_TOKEN_AUTH_METHOD="client_secret_basic" # Allowed values: "client_secret_basic", "client_secret_post", "none" +# AUTH_OAUTH_ENABLE_PKCE="true" # PKCE is enabled by default. Set to "false" to disable. + diff --git a/prompts.config.ts b/prompts.config.ts index 84bd850a..106c49e6 100644 --- a/prompts.config.ts +++ b/prompts.config.ts @@ -33,7 +33,7 @@ export default defineConfig({ // Authentication plugins auth: { - // Available: "credentials" | "google" | "azure" | "github" | "apple" | custom + // Available: "credentials" | "google" | "azure" | "github" | "apple" | "oidc" | "oauth" | custom // Use `providers` array to enable multiple auth providers providers: ["github", "google", "apple"], // Allow public registration (only applies to credentials provider) diff --git a/src/__tests__/lib/plugins/auth/oauth.test.ts b/src/__tests__/lib/plugins/auth/oauth.test.ts new file mode 100644 index 00000000..b1c0b19f --- /dev/null +++ b/src/__tests__/lib/plugins/auth/oauth.test.ts @@ -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 + }); +}); diff --git a/src/__tests__/lib/plugins/auth/oidc.test.ts b/src/__tests__/lib/plugins/auth/oidc.test.ts new file mode 100644 index 00000000..869d42f6 --- /dev/null +++ b/src/__tests__/lib/plugins/auth/oidc.test.ts @@ -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 + }); + + 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"); + }); +}); diff --git a/src/lib/plugins/auth/index.ts b/src/lib/plugins/auth/index.ts index 95f85486..5fbff2d0 100644 --- a/src/lib/plugins/auth/index.ts +++ b/src/lib/plugins/auth/index.ts @@ -4,6 +4,8 @@ import { googlePlugin } from "./google"; import { azurePlugin } from "./azure"; import { githubPlugin } from "./github"; import { applePlugin } from "./apple"; +import { oidcPlugin } from "./oidc"; +import { oauthPlugin } from "./oauth"; // Register all built-in auth plugins export function registerBuiltInAuthPlugins(): void { @@ -12,6 +14,8 @@ export function registerBuiltInAuthPlugins(): void { registerAuthPlugin(azurePlugin); registerAuthPlugin(githubPlugin); registerAuthPlugin(applePlugin); + registerAuthPlugin(oidcPlugin); + registerAuthPlugin(oauthPlugin); } -export { credentialsPlugin, googlePlugin, azurePlugin, githubPlugin, applePlugin }; \ No newline at end of file +export { credentialsPlugin, googlePlugin, azurePlugin, githubPlugin, applePlugin, oidcPlugin, oauthPlugin }; \ No newline at end of file diff --git a/src/lib/plugins/auth/oauth.ts b/src/lib/plugins/auth/oauth.ts new file mode 100644 index 00000000..439cf4ea --- /dev/null +++ b/src/lib/plugins/auth/oauth.ts @@ -0,0 +1,86 @@ +import type { OAuth2Config } from "next-auth/providers"; +import type { AuthPlugin } from "../types"; + +export interface GenericOAuthProfile extends Record { + sub?: string; + id?: string; + name?: string; + preferred_username?: string; + email?: string; + picture?: string; + avatar_url?: string; + nickname?: string; +} + +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 = { + 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; + }, +}; diff --git a/src/lib/plugins/auth/oidc.ts b/src/lib/plugins/auth/oidc.ts new file mode 100644 index 00000000..49cc1654 --- /dev/null +++ b/src/lib/plugins/auth/oidc.ts @@ -0,0 +1,83 @@ +import type { OIDCConfig } from "next-auth/providers"; +import type { AuthPlugin } from "../types"; + +export interface GenericOIDCProfile extends Record { + sub: string; + name?: string; + preferred_username?: string; + email?: string; + picture?: string; + avatar_url?: string; + nickname?: string; +} + +export const oidcPlugin: AuthPlugin = { + id: "oidc", + name: "Generic OIDC", + getProvider: () => { + const tokenAuthMethod = process.env.AUTH_OIDC_TOKEN_AUTH_METHOD; + const clientId = process.env.AUTH_OIDC_ID; + const clientSecret = process.env.AUTH_OIDC_SECRET; + const issuer = process.env.AUTH_OIDC_ISSUER; + const name = process.env.AUTH_OIDC_NAME; + + if (!clientId || !issuer || !name) { + throw new Error("OIDC configuration is missing required environment variables: AUTH_OIDC_ID, AUTH_OIDC_ISSUER, or AUTH_OIDC_NAME"); + } + + if (!clientSecret && tokenAuthMethod !== "none") { + throw new Error("AUTH_OIDC_SECRET is required unless AUTH_OIDC_TOKEN_AUTH_METHOD is set to 'none'"); + } + + const provider: OIDCConfig = { + id: "oidc", + name, + type: "oidc", + ...(process.env.AUTH_OIDC_LOGO ? { style: { logo: process.env.AUTH_OIDC_LOGO } } : {}), + clientId, + ...(clientSecret ? { clientSecret } : {}), + issuer, + wellKnown: process.env.AUTH_OIDC_WELLKNOWN || `${issuer}/.well-known/openid-configuration`, + authorization: { + ...(process.env.AUTH_OIDC_AUTHORIZATION_URL ? { url: process.env.AUTH_OIDC_AUTHORIZATION_URL } : {}), + params: { scope: process.env.AUTH_OIDC_SCOPE || "openid email profile" } + }, + client: { + token_endpoint_auth_method: + tokenAuthMethod === "client_secret_post" ? "client_secret_post" : + tokenAuthMethod === "none" ? "none" : + "client_secret_basic", + }, + ...(process.env.AUTH_OIDC_TOKEN_URL ? { token: process.env.AUTH_OIDC_TOKEN_URL } : {}), + ...(process.env.AUTH_OIDC_USERINFO_URL ? { userinfo: process.env.AUTH_OIDC_USERINFO_URL } : {}), + ...(process.env.AUTH_OIDC_JWKS_URL ? { jwks_endpoint: process.env.AUTH_OIDC_JWKS_URL } : {}), + // PKCE is enabled by default for OIDC. It must be explicitly set to "false" to be disabled. + ...(process.env.AUTH_OIDC_ENABLE_PKCE === "false" ? { checks: ["state"] } : {}), + profile(profile) { + const id = profile.sub; // OIDC spec requires 'sub' as the unique identifier + 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("OIDC profile is missing the required `sub` claim."); + } + if (!email || typeof email !== "string") { + throw new Error("OIDC profile is missing a valid email address."); + } + + 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; + }, +};