Compare commits

..

6 Commits

Author SHA1 Message Date
Arunavo Ray
79e0086a72 sso: polyfill buffer for browser 2025-10-22 18:13:36 +05:30
Arunavo Ray
dc340666ef v3.8.7 2025-10-22 17:54:25 +05:30
ARUNAVO RAY
8b50a07c68 Merge pull request #124 from RayLabsHQ/fix/sso-stability
Fix/sso stability
2025-10-22 17:54:01 +05:30
Arunavo Ray
7dab4fb1d5 sso: drop legacy migration helper 2025-10-22 16:34:37 +05:30
Arunavo Ray
847823bbf8 sso: normalize provider config via discovery 2025-10-22 16:33:33 +05:30
Arunavo Ray
e4e54722cf auth: bump better auth beta 2025-10-22 16:28:59 +05:30
9 changed files with 461 additions and 80 deletions

View File

@@ -8,7 +8,7 @@
"@astrojs/mdx": "4.3.7",
"@astrojs/node": "9.5.0",
"@astrojs/react": "^4.4.0",
"@better-auth/sso": "^1.3.28",
"@better-auth/sso": "1.4.0-beta.12",
"@octokit/plugin-throttling": "^11.0.2",
"@octokit/rest": "^22.0.0",
"@radix-ui/react-accordion": "^1.2.12",
@@ -36,7 +36,8 @@
"@types/react-dom": "^19.2.2",
"astro": "^5.14.8",
"bcryptjs": "^3.0.2",
"better-auth": "^1.3.28",
"better-auth": "1.4.0-beta.12",
"buffer": "^6.0.3",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -149,11 +150,11 @@
"@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"@better-auth/core": ["@better-auth/core@1.3.28", "", { "dependencies": { "zod": "^4.1.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.18", "better-call": "1.0.19", "better-sqlite3": "^12.4.1", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-iZOGKlXaNEIEj0Q3z7+REE94I89YUJ0sel/1pvm1qqdHkm59G+ToTysHtyTcLYby3+UtAeJRKyFAY0nwJH0H7A=="],
"@better-auth/core": ["@better-auth/core@1.4.0-beta.12", "", { "dependencies": { "zod": "^4.1.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.18", "better-call": "1.0.24", "better-sqlite3": "^12.4.1", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-2GisAGuSVZS4gtnwP5Owk3RyC6GevZe9zcODTrtbwRCvBTrHUmu0j6bcklK9uNG8DaWDmzCK1+VGA5qIHzg5Pw=="],
"@better-auth/sso": ["@better-auth/sso@1.3.28", "", { "dependencies": { "@better-fetch/fetch": "1.1.18", "fast-xml-parser": "^5.2.5", "jose": "^6.1.0", "oauth2-mock-server": "^7.2.1", "samlify": "^2.10.1", "zod": "^4.1.5" }, "peerDependencies": { "better-auth": "1.3.28" } }, "sha512-BeuQFB/tWKR3Nx89fiD6e0Ei5VoKmman0VDBkoOIu+P3PGdSSzvbmBUTZi8aao+tAPoD6/Z5gje+oiQBDAnp4w=="],
"@better-auth/sso": ["@better-auth/sso@1.4.0-beta.12", "", { "dependencies": { "@better-fetch/fetch": "1.1.18", "fast-xml-parser": "^5.2.5", "jose": "^6.1.0", "oauth2-mock-server": "^7.2.1", "samlify": "^2.10.1", "zod": "^4.1.5" }, "peerDependencies": { "better-auth": "1.4.0-beta.12" } }, "sha512-iuRuy59J3yXQihZJ34rqYClWyuVjSkxuBkdFblccKbOhNy7pmRO1lfmBMpyeth3ET5Cp0PDVV/z1XBbDcQp0LA=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.3.28", "", { "dependencies": { "@better-auth/core": "1.3.28", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.18" } }, "sha512-qZtV82IFuyQZc2c37VkiDgO/qfqPnJuWIyeC/iFK1AA5N8RSuC2+CVIH1sNDytPXUAthbYeOzcOCW2YEkgz1Ow=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.4.0-beta.12", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.18" }, "peerDependencies": { "@better-auth/core": "1.4.0-beta.12" } }, "sha512-pQ5HITRGXMHQPcPCDnz0xlxFqqxvpD4kQMvY6cdt1vDsPVePHAj9R3S318XEfaw3NAgtw3af/wCN6eBt2u4Kew=="],
"@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="],
@@ -697,9 +698,9 @@
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"better-auth": ["better-auth@1.3.28", "", { "dependencies": { "@better-auth/core": "1.3.28", "@better-auth/telemetry": "1.3.28", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.18", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "1.0.19", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.5" } }, "sha512-fSaeRsTSkzCSSKREFsm7z7TsTMC8ghGrwCN+mumxCZiyc8Fh/UThUwURlTJmsR0YVB0DMR8ejQH+c38WhdQslQ=="],
"better-auth": ["better-auth@1.4.0-beta.12", "", { "dependencies": { "@better-auth/core": "1.4.0-beta.12", "@better-auth/telemetry": "1.4.0-beta.12", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.18", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "1.0.24", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.5" } }, "sha512-IvrSBmQkHgOinDh6JyJCoKwbMPmHpkmt98/0hBU9Nc0s7Y7u72AOx1Z35J2dRQxxX4SzvFQ9pHqlV6wPnm72Ww=="],
"better-call": ["better-call@1.0.19", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw=="],
"better-call": ["better-call@1.0.24", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-iGqL29cstPp4xLD2MjKL1EmyAqQHjYS+cBMt4W27rPs3vf+kuqkVPA0NYaf7JciBOzVsJdNj4cbZWXC5TardWQ=="],
"better-sqlite3": ["better-sqlite3@12.4.1", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ=="],
@@ -719,7 +720,7 @@
"browserslist": ["browserslist@4.24.5", "", { "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
@@ -1925,6 +1926,8 @@
"basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],

View File

@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.8.6",
"version": "3.8.7",
"engines": {
"bun": ">=1.2.9"
},
@@ -46,7 +46,7 @@
"@astrojs/mdx": "4.3.7",
"@astrojs/node": "9.5.0",
"@astrojs/react": "^4.4.0",
"@better-auth/sso": "^1.3.28",
"@better-auth/sso": "1.4.0-beta.12",
"@octokit/plugin-throttling": "^11.0.2",
"@octokit/rest": "^22.0.0",
"@radix-ui/react-accordion": "^1.2.12",
@@ -74,7 +74,8 @@
"@types/react-dom": "^19.2.2",
"astro": "^5.14.8",
"bcryptjs": "^3.0.2",
"better-auth": "^1.3.28",
"buffer": "^6.0.3",
"better-auth": "1.4.0-beta.12",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -1,3 +1,4 @@
import "@/lib/polyfills/buffer";
import { createAuthClient } from "better-auth/react";
import { oidcClient } from "better-auth/client/plugins";
import { ssoClient } from "@better-auth/sso/client";
@@ -60,4 +61,4 @@ export type Session = BetterAuthSession & {
};
export type AuthUser = BetterAuthUser & {
username?: string | null;
};
};

View File

@@ -171,4 +171,4 @@ export const auth = betterAuth({
});
// Export type for use in other parts of the app
export type Auth = typeof auth;
export type Auth = typeof auth;

View File

@@ -0,0 +1,5 @@
import { Buffer } from "buffer";
if (typeof globalThis !== "undefined" && (globalThis as any).Buffer === undefined) {
(globalThis as any).Buffer = Buffer;
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "bun:test";
import { normalizeOidcProviderConfig, OidcConfigError } from "./oidc-config";
const issuer = "https://auth.example.com";
describe("normalizeOidcProviderConfig", () => {
it("returns provided endpoints when complete", async () => {
const result = await normalizeOidcProviderConfig(issuer, {
clientId: "client",
clientSecret: "secret",
authorizationEndpoint: "https://auth.example.com/auth",
tokenEndpoint: "https://auth.example.com/token",
jwksEndpoint: "https://auth.example.com/jwks",
userInfoEndpoint: "https://auth.example.com/userinfo",
scopes: ["openid", "email"],
pkce: false,
}, async () => {
throw new Error("fetch should not be called when endpoints are provided");
});
expect(result.oidcConfig.authorizationEndpoint).toBe("https://auth.example.com/auth");
expect(result.oidcConfig.tokenEndpoint).toBe("https://auth.example.com/token");
expect(result.oidcConfig.jwksEndpoint).toBe("https://auth.example.com/jwks");
expect(result.oidcConfig.userInfoEndpoint).toBe("https://auth.example.com/userinfo");
expect(result.oidcConfig.scopes).toEqual(["openid", "email"]);
expect(result.oidcConfig.pkce).toBe(false);
});
it("derives missing fields from discovery", async () => {
const fetchMock = async () =>
new Response(JSON.stringify({
authorization_endpoint: "https://auth.example.com/auth",
token_endpoint: "https://auth.example.com/token",
jwks_uri: "https://auth.example.com/jwks",
userinfo_endpoint: "https://auth.example.com/userinfo",
scopes_supported: ["openid", "email", "profile"],
}));
const result = await normalizeOidcProviderConfig(issuer, {
clientId: "client",
clientSecret: "secret",
}, fetchMock);
expect(result.oidcConfig.authorizationEndpoint).toBe("https://auth.example.com/auth");
expect(result.oidcConfig.tokenEndpoint).toBe("https://auth.example.com/token");
expect(result.oidcConfig.jwksEndpoint).toBe("https://auth.example.com/jwks");
expect(result.oidcConfig.userInfoEndpoint).toBe("https://auth.example.com/userinfo");
expect(result.oidcConfig.scopes).toEqual(["openid", "email", "profile"]);
});
it("throws for invalid issuer URL", async () => {
await expect(
normalizeOidcProviderConfig("not-a-url", {}),
).rejects.toBeInstanceOf(OidcConfigError);
});
});

202
src/lib/sso/oidc-config.ts Normal file
View File

@@ -0,0 +1,202 @@
import { z } from "zod";
const DEFAULT_SCOPES = ["openid", "email", "profile"] as const;
const DISCOVERY_TIMEOUT_MS = 10000;
const discoverySchema = z.object({
issuer: z.string().url().optional(),
authorization_endpoint: z.string().url().optional(),
token_endpoint: z.string().url().optional(),
userinfo_endpoint: z.string().url().optional(),
jwks_uri: z.string().url().optional(),
scopes_supported: z.array(z.string()).optional(),
});
export class OidcConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "OidcConfigError";
}
}
export type RawOidcConfig = {
clientId?: string;
clientSecret?: string;
authorizationEndpoint?: string;
tokenEndpoint?: string;
jwksEndpoint?: string;
userInfoEndpoint?: string;
discoveryEndpoint?: string;
scopes?: string[];
pkce?: boolean;
mapping?: ProviderMapping;
};
export type ProviderMapping = {
id: string;
email: string;
emailVerified?: string;
name?: string;
image?: string;
firstName?: string;
lastName?: string;
};
export type NormalizedOidcConfig = {
oidcConfig: {
clientId?: string;
clientSecret?: string;
authorizationEndpoint: string;
tokenEndpoint: string;
jwksEndpoint?: string;
userInfoEndpoint?: string;
discoveryEndpoint: string;
scopes: string[];
pkce: boolean;
};
mapping: ProviderMapping;
};
type FetchFn = typeof fetch;
function cleanUrl(value: string | undefined, field: string): string | undefined {
if (!value || typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
try {
return new URL(trimmed).toString();
} catch {
throw new OidcConfigError(`Invalid ${field} URL: ${value}`);
}
}
function sanitizeScopes(scopes: string[] | undefined, fallback: readonly string[]): string[] {
const candidates = Array.isArray(scopes) ? scopes : [];
const sanitized = candidates
.map(scope => scope?.trim())
.filter((scope): scope is string => Boolean(scope));
if (sanitized.length === 0) {
return [...fallback];
}
return Array.from(new Set(sanitized));
}
async function fetchDiscoveryDocument(url: string, fetchFn: FetchFn): Promise<z.infer<typeof discoverySchema>> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT_MS);
try {
const response = await fetchFn(url, {
signal: controller.signal,
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new OidcConfigError(`OIDC discovery request failed (${response.status} ${response.statusText})`);
}
let payload: unknown;
try {
payload = await response.json();
} catch {
throw new OidcConfigError("OIDC discovery response is not valid JSON");
}
const parsed = discoverySchema.parse(payload);
if (!parsed.authorization_endpoint || !parsed.token_endpoint) {
throw new OidcConfigError("OIDC discovery document is missing required endpoints");
}
return parsed;
} catch (error) {
if (error instanceof OidcConfigError) {
throw error;
}
if (error instanceof Error && error.name === "AbortError") {
throw new OidcConfigError(`OIDC discovery timed out after ${DISCOVERY_TIMEOUT_MS / 1000}s`);
}
throw new OidcConfigError(`Failed to fetch OIDC discovery document: ${error instanceof Error ? error.message : "unknown error"}`);
} finally {
clearTimeout(timeoutId);
}
}
export async function normalizeOidcProviderConfig(
issuer: string,
rawConfig: RawOidcConfig,
fetchFn: FetchFn = fetch,
): Promise<NormalizedOidcConfig> {
if (!issuer || typeof issuer !== "string") {
throw new OidcConfigError("Issuer is required");
}
let normalizedIssuer: string;
try {
const issuerUrl = new URL(issuer.trim());
normalizedIssuer = issuerUrl.toString().replace(/\/$/, "");
} catch {
throw new OidcConfigError(`Invalid issuer URL: ${issuer}`);
}
const discoveryEndpoint = cleanUrl(
rawConfig.discoveryEndpoint,
"discovery endpoint",
) ?? `${normalizedIssuer}/.well-known/openid-configuration`;
const authorizationEndpoint = cleanUrl(rawConfig.authorizationEndpoint, "authorization endpoint");
const tokenEndpoint = cleanUrl(rawConfig.tokenEndpoint, "token endpoint");
const jwksEndpoint = cleanUrl(rawConfig.jwksEndpoint, "JWKS endpoint");
const userInfoEndpoint = cleanUrl(rawConfig.userInfoEndpoint, "userinfo endpoint");
const providedScopes = Array.isArray(rawConfig.scopes) ? rawConfig.scopes : undefined;
let scopes = sanitizeScopes(providedScopes, DEFAULT_SCOPES);
const shouldFetchDiscovery =
!authorizationEndpoint ||
!tokenEndpoint ||
!jwksEndpoint ||
!userInfoEndpoint ||
!providedScopes ||
providedScopes.length === 0;
let resolvedAuthorization = authorizationEndpoint;
let resolvedToken = tokenEndpoint;
let resolvedJwks = jwksEndpoint;
let resolvedUserInfo = userInfoEndpoint;
if (shouldFetchDiscovery) {
const discovery = await fetchDiscoveryDocument(discoveryEndpoint, fetchFn);
resolvedAuthorization = resolvedAuthorization ?? discovery.authorization_endpoint;
resolvedToken = resolvedToken ?? discovery.token_endpoint;
resolvedJwks = resolvedJwks ?? discovery.jwks_uri;
resolvedUserInfo = resolvedUserInfo ?? discovery.userinfo_endpoint;
if (!providedScopes || providedScopes.length === 0) {
scopes = sanitizeScopes(discovery.scopes_supported, DEFAULT_SCOPES);
}
}
if (!resolvedAuthorization || !resolvedToken) {
throw new OidcConfigError("OIDC configuration must include authorization and token endpoints");
}
return {
oidcConfig: {
clientId: rawConfig.clientId,
clientSecret: rawConfig.clientSecret,
authorizationEndpoint: resolvedAuthorization,
tokenEndpoint: resolvedToken,
jwksEndpoint: resolvedJwks,
userInfoEndpoint: resolvedUserInfo,
discoveryEndpoint,
scopes,
pkce: rawConfig.pkce !== false,
},
mapping: rawConfig.mapping ?? {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "name",
image: "picture",
},
};
}

View File

@@ -2,6 +2,10 @@ import type { APIContext } from "astro";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers";
import { auth } from "@/lib/auth";
import { db, ssoProviders } from "@/lib/db";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import { normalizeOidcProviderConfig, OidcConfigError } from "@/lib/sso/oidc-config";
// POST /api/auth/sso/register - Register a new SSO provider using Better Auth
export async function POST(context: APIContext) {
@@ -104,43 +108,37 @@ export async function POST(context: APIContext) {
userInfoEndpoint,
scopes,
pkce = true,
mapping = {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "name",
image: "picture",
}
mapping,
} = body;
// Use provided scopes or default if not specified
const finalScopes = scopes || ["openid", "email", "profile"];
try {
const normalized = await normalizeOidcProviderConfig(validatedIssuer, {
clientId,
clientSecret,
authorizationEndpoint,
tokenEndpoint,
jwksEndpoint,
userInfoEndpoint,
discoveryEndpoint,
scopes,
pkce,
mapping,
});
// Validate endpoint URLs if provided
const validateUrl = (url: string | undefined, name: string): string | undefined => {
if (!url) return undefined;
if (typeof url !== 'string' || url.trim() === '') return undefined;
try {
const validatedUrl = new URL(url.trim());
return validatedUrl.toString();
} catch (e) {
console.warn(`Invalid ${name} URL: ${url}, skipping`);
return undefined;
registrationBody.oidcConfig = normalized.oidcConfig;
registrationBody.mapping = normalized.mapping;
} catch (error) {
if (error instanceof OidcConfigError) {
return new Response(
JSON.stringify({ error: error.message }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
};
registrationBody.oidcConfig = {
clientId: clientId || undefined,
clientSecret: clientSecret || undefined,
authorizationEndpoint: validateUrl(authorizationEndpoint, 'authorization endpoint'),
tokenEndpoint: validateUrl(tokenEndpoint, 'token endpoint'),
jwksEndpoint: validateUrl(jwksEndpoint, 'JWKS endpoint'),
discoveryEndpoint: validateUrl(discoveryEndpoint, 'discovery endpoint'),
userInfoEndpoint: validateUrl(userInfoEndpoint, 'userinfo endpoint'),
scopes: finalScopes,
pkce,
};
registrationBody.mapping = mapping;
throw error;
}
}
// Get the user's auth headers to make the request
@@ -168,7 +166,52 @@ export async function POST(context: APIContext) {
}
const result = await response.json();
// Mirror provider entry into local SSO table for UI listing
try {
const existing = await db
.select()
.from(ssoProviders)
.where(eq(ssoProviders.providerId, registrationBody.providerId))
.limit(1);
const values: any = {
issuer: registrationBody.issuer,
domain: registrationBody.domain,
organizationId: registrationBody.organizationId,
updatedAt: new Date(),
};
if (registrationBody.oidcConfig) {
values.oidcConfig = JSON.stringify({
...registrationBody.oidcConfig,
mapping: registrationBody.mapping,
});
}
if (existing.length > 0) {
await db
.update(ssoProviders)
.set(values)
.where(eq(ssoProviders.id, existing[0].id));
} else {
await db.insert(ssoProviders).values({
id: nanoid(),
issuer: registrationBody.issuer,
domain: registrationBody.domain,
oidcConfig: JSON.stringify({
...registrationBody.oidcConfig,
mapping: registrationBody.mapping,
}),
userId: user.id,
providerId: registrationBody.providerId,
organizationId: registrationBody.organizationId,
});
}
} catch (mirroringError) {
console.warn("Failed to mirror SSO provider to local DB:", mirroringError);
}
return new Response(JSON.stringify(result), {
status: 201,
headers: { "Content-Type": "application/json" },
@@ -199,4 +242,4 @@ export async function GET(context: APIContext) {
} catch (error) {
return createSecureErrorResponse(error, "SSO provider listing");
}
}
}

View File

@@ -4,6 +4,7 @@ import { requireAuth } from "@/lib/utils/auth-helpers";
import { db, ssoProviders } from "@/lib/db";
import { nanoid } from "nanoid";
import { eq } from "drizzle-orm";
import { normalizeOidcProviderConfig, OidcConfigError, type RawOidcConfig } from "@/lib/sso/oidc-config";
// GET /api/sso/providers - List all SSO providers
export async function GET(context: APIContext) {
@@ -45,10 +46,12 @@ export async function POST(context: APIContext) {
tokenEndpoint,
jwksEndpoint,
userInfoEndpoint,
discoveryEndpoint,
mapping,
providerId,
organizationId,
scopes,
pkce,
} = body;
// Validate required fields
@@ -79,22 +82,51 @@ export async function POST(context: APIContext) {
);
}
// Create OIDC config object
const oidcConfig = {
clientId,
clientSecret,
authorizationEndpoint,
tokenEndpoint,
jwksEndpoint,
userInfoEndpoint,
scopes: scopes || ["openid", "email", "profile"],
mapping: mapping || {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "name",
image: "picture",
},
// Clean issuer URL (remove trailing slash); validate format
let cleanIssuer = issuer;
try {
const issuerUrl = new URL(issuer.toString().trim());
cleanIssuer = issuerUrl.toString().replace(/\/$/, "");
} catch {
return new Response(
JSON.stringify({ error: `Invalid issuer URL format: ${issuer}` }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
let normalized;
try {
normalized = await normalizeOidcProviderConfig(cleanIssuer, {
clientId,
clientSecret,
authorizationEndpoint,
tokenEndpoint,
jwksEndpoint,
userInfoEndpoint,
discoveryEndpoint,
scopes,
pkce,
mapping,
});
} catch (error) {
if (error instanceof OidcConfigError) {
return new Response(
JSON.stringify({ error: error.message }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
throw error;
}
const storedOidcConfig = {
...normalized.oidcConfig,
mapping: normalized.mapping,
};
// Insert new provider
@@ -102,9 +134,9 @@ export async function POST(context: APIContext) {
.insert(ssoProviders)
.values({
id: nanoid(),
issuer,
issuer: cleanIssuer,
domain,
oidcConfig: JSON.stringify(oidcConfig),
oidcConfig: JSON.stringify(storedOidcConfig),
userId: user.id,
providerId,
organizationId,
@@ -156,7 +188,9 @@ export async function PUT(context: APIContext) {
tokenEndpoint,
jwksEndpoint,
userInfoEndpoint,
discoveryEndpoint,
scopes,
pkce,
organizationId,
} = body;
@@ -179,26 +213,62 @@ export async function PUT(context: APIContext) {
// Parse existing config
const existingConfig = JSON.parse(existingProvider.oidcConfig);
const effectiveIssuer = issuer || existingProvider.issuer;
// Create updated OIDC config
const updatedOidcConfig = {
...existingConfig,
clientId: clientId || existingConfig.clientId,
clientSecret: clientSecret || existingConfig.clientSecret,
authorizationEndpoint: authorizationEndpoint || existingConfig.authorizationEndpoint,
tokenEndpoint: tokenEndpoint || existingConfig.tokenEndpoint,
jwksEndpoint: jwksEndpoint || existingConfig.jwksEndpoint,
userInfoEndpoint: userInfoEndpoint || existingConfig.userInfoEndpoint,
scopes: scopes || existingConfig.scopes || ["openid", "email", "profile"],
let cleanIssuer = effectiveIssuer;
try {
const issuerUrl = new URL(effectiveIssuer.toString().trim());
cleanIssuer = issuerUrl.toString().replace(/\/$/, "");
} catch {
return new Response(
JSON.stringify({ error: `Invalid issuer URL format: ${effectiveIssuer}` }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const mergedConfig: RawOidcConfig = {
clientId: clientId ?? existingConfig.clientId,
clientSecret: clientSecret ?? existingConfig.clientSecret,
authorizationEndpoint: authorizationEndpoint ?? existingConfig.authorizationEndpoint,
tokenEndpoint: tokenEndpoint ?? existingConfig.tokenEndpoint,
jwksEndpoint: jwksEndpoint ?? existingConfig.jwksEndpoint,
userInfoEndpoint: userInfoEndpoint ?? existingConfig.userInfoEndpoint,
discoveryEndpoint: discoveryEndpoint ?? existingConfig.discoveryEndpoint,
scopes: scopes ?? existingConfig.scopes,
pkce: pkce ?? existingConfig.pkce,
mapping: existingConfig.mapping,
};
let normalized;
try {
normalized = await normalizeOidcProviderConfig(cleanIssuer, mergedConfig);
} catch (error) {
if (error instanceof OidcConfigError) {
return new Response(
JSON.stringify({ error: error.message }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
throw error;
}
const storedOidcConfig = {
...normalized.oidcConfig,
mapping: normalized.mapping,
};
// Update provider
const [updatedProvider] = await db
.update(ssoProviders)
.set({
issuer: issuer || existingProvider.issuer,
issuer: cleanIssuer,
domain: domain || existingProvider.domain,
oidcConfig: JSON.stringify(updatedOidcConfig),
oidcConfig: JSON.stringify(storedOidcConfig),
organizationId: organizationId !== undefined ? organizationId : existingProvider.organizationId,
updatedAt: new Date(),
})
@@ -259,4 +329,4 @@ export async function DELETE(context: APIContext) {
} catch (error) {
return createSecureErrorResponse(error, "SSO providers API");
}
}
}