feat(auth): add generic OIDC and OAuth 2.0 plugins (#1056)

This commit is contained in:
Amayaranjan Das
2026-05-02 11:54:07 +05:30
committed by GitHub
parent fdf407a3e4
commit bf1de55c8d
7 changed files with 450 additions and 3 deletions
+49 -1
View File
@@ -65,4 +65,52 @@ CRON_SECRET="your-secret-key-here"
# SENTRY_AUTH_TOKEN=sentry-auth-token # SENTRY_AUTH_TOKEN=sentry-auth-token
# GOOGLE_ADSENSE_ACCOUNT=ca-pub-xxxxxxxxxxxxxxxx # 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: <your-domain>/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: <your-domain>/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.
+1 -1
View File
@@ -33,7 +33,7 @@ export default defineConfig({
// Authentication plugins // Authentication plugins
auth: { 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 // Use `providers` array to enable multiple auth providers
providers: ["github", "google", "apple"], providers: ["github", "google", "apple"],
// Allow public registration (only applies to credentials provider) // Allow public registration (only applies to credentials provider)
@@ -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
});
});
@@ -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");
});
});
+5 -1
View File
@@ -4,6 +4,8 @@ import { googlePlugin } from "./google";
import { azurePlugin } from "./azure"; import { azurePlugin } from "./azure";
import { githubPlugin } from "./github"; import { githubPlugin } from "./github";
import { applePlugin } from "./apple"; import { applePlugin } from "./apple";
import { oidcPlugin } from "./oidc";
import { oauthPlugin } from "./oauth";
// Register all built-in auth plugins // Register all built-in auth plugins
export function registerBuiltInAuthPlugins(): void { export function registerBuiltInAuthPlugins(): void {
@@ -12,6 +14,8 @@ export function registerBuiltInAuthPlugins(): void {
registerAuthPlugin(azurePlugin); registerAuthPlugin(azurePlugin);
registerAuthPlugin(githubPlugin); registerAuthPlugin(githubPlugin);
registerAuthPlugin(applePlugin); registerAuthPlugin(applePlugin);
registerAuthPlugin(oidcPlugin);
registerAuthPlugin(oauthPlugin);
} }
export { credentialsPlugin, googlePlugin, azurePlugin, githubPlugin, applePlugin }; export { credentialsPlugin, googlePlugin, azurePlugin, githubPlugin, applePlugin, oidcPlugin, oauthPlugin };
+86
View File
@@ -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;
}
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;
},
};
+83
View File
@@ -0,0 +1,83 @@
import type { OIDCConfig } from "next-auth/providers";
import type { AuthPlugin } from "../types";
export interface GenericOIDCProfile extends Record<string, unknown> {
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<GenericOIDCProfile> = {
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;
},
};