Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,51 @@ CRON_SECRET="your-secret-key-here"

# GOOGLE_ADSENSE_ACCOUNT=ca-pub-xxxxxxxxxxxxxxxx
# NEXT_PUBLIC_EZOIC_ENABLED=true
# EZOIC_SITE_DOMAIN=prompts.chat
# EZOIC_SITE_DOMAIN=prompts.chat

# ==============================================================================
# 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"
Comment thread
Amaya54 marked this conversation as resolved.
# AUTH_OIDC_ISSUER="https://oidc.example.com"
# AUTH_OIDC_WELLKNOWN="https://oidc.example.com/.well-known/openid-configuration"
Comment thread
Amaya54 marked this conversation as resolved.
# 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"
Comment thread
Amaya54 marked this conversation as resolved.
# 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.
2 changes: 1 addition & 1 deletion prompts.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
131 changes: 131 additions & 0 deletions src/__tests__/lib/plugins/auth/oauth.test.ts
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
});
});
95 changes: 95 additions & 0 deletions src/__tests__/lib/plugins/auth/oidc.test.ts
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
Comment thread
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");
});
});
6 changes: 5 additions & 1 deletion src/lib/plugins/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -12,6 +14,8 @@ export function registerBuiltInAuthPlugins(): void {
registerAuthPlugin(azurePlugin);
registerAuthPlugin(githubPlugin);
registerAuthPlugin(applePlugin);
registerAuthPlugin(oidcPlugin);
registerAuthPlugin(oauthPlugin);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export { credentialsPlugin, googlePlugin, azurePlugin, githubPlugin, applePlugin };
export { credentialsPlugin, googlePlugin, azurePlugin, githubPlugin, applePlugin, oidcPlugin, oauthPlugin };
86 changes: 86 additions & 0 deletions src/lib/plugins/auth/oauth.ts
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;
}
Comment thread
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;
},
};
Loading