mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-07 12:06:46 +03:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79e0086a72 | ||
|
|
dc340666ef | ||
|
|
8b50a07c68 | ||
|
|
7dab4fb1d5 | ||
|
|
847823bbf8 | ||
|
|
e4e54722cf |
19
bun.lock
19
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
5
src/lib/polyfills/buffer.ts
Normal file
5
src/lib/polyfills/buffer.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
if (typeof globalThis !== "undefined" && (globalThis as any).Buffer === undefined) {
|
||||
(globalThis as any).Buffer = Buffer;
|
||||
}
|
||||
56
src/lib/sso/oidc-config.test.ts
Normal file
56
src/lib/sso/oidc-config.test.ts
Normal 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
202
src/lib/sso/oidc-config.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user