feat(auth): add generic OIDC and OAuth 2.0 plugins (#1056)
This commit is contained in:
+49
-1
@@ -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
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user