mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-07-07 02:11:00 +02:00
fix(sso): repair SSO login bounce + migrate to @better-auth/oauth-provider (#307)
Resolves #306. SSO sign-in via OIDC (Authentik / Keycloak / etc.) now links the SSO identity to an existing email/password admin instead of bouncing to /login with `?error=UNKNOWN`. Account-linking is gated on the operator-supplied **Domain** field — cross-domain claims from a compromised IdP are refused. Also bundles the deprecated `oidcProvider` → `@better-auth/oauth-provider` migration. **Operators using the OAuth-provider feature must rotate registered client secrets after upgrade** (legacy plaintext → hashed storage; see the 0012 migration notes). Verified end-to-end on the pr-307 image against a real Authentik instance: SSO login lands on the dashboard, `accounts` table gets both `credential` and `authentik` rows for the same user. See PR description for full details.
This commit is contained in:
@@ -90,41 +90,29 @@ export default function ConsentPage() {
|
||||
const handleConsent = async (accept: boolean) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// The OAuth provider redirected here with the authorization request in
|
||||
// the query string (client_id, scope, code). Hand that back via
|
||||
// `oauth_query` so the provider can resume the flow. It validates the
|
||||
// redirect URI server-side and returns the URL to navigate to — either
|
||||
// with an authorization code (accept) or an error (deny).
|
||||
const oauthQuery = window.location.search.replace(/^\?/, '');
|
||||
const result = await authClient.oauth2.consent({
|
||||
accept,
|
||||
oauth_query: oauthQuery,
|
||||
scope: Array.from(selectedScopes).join(' '),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || 'Consent failed');
|
||||
}
|
||||
|
||||
// The consent method should handle the redirect
|
||||
if (!accept) {
|
||||
// If denied, redirect back to the application with error
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const redirectUri = params.get('redirect_uri');
|
||||
|
||||
if (redirectUri && application) {
|
||||
// Validate redirect URI against authorized URIs
|
||||
const authorizedUris = parseRedirectUris(application.redirectURLs);
|
||||
|
||||
if (isValidRedirectUri(redirectUri, authorizedUris)) {
|
||||
try {
|
||||
// Parse and reconstruct the URL to ensure it's safe
|
||||
const url = new URL(redirectUri);
|
||||
url.searchParams.set('error', 'access_denied');
|
||||
|
||||
// Safe to redirect - URI has been validated and sanitized
|
||||
window.location.href = url.toString();
|
||||
} catch (e) {
|
||||
console.error('Failed to parse redirect URI:', e);
|
||||
setError('Invalid redirect URI');
|
||||
}
|
||||
} else {
|
||||
console.error('Unauthorized redirect URI:', redirectUri);
|
||||
setError('Invalid redirect URI');
|
||||
}
|
||||
}
|
||||
const redirectUri = (result.data as { redirect_uri?: string } | undefined)?.redirect_uri;
|
||||
if (redirectUri) {
|
||||
// The redirect URI is produced and validated server-side.
|
||||
window.location.href = redirectUri;
|
||||
} else if (!accept) {
|
||||
// No redirect target returned on denial — return to the app.
|
||||
window.location.href = '/';
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "@/lib/polyfills/buffer";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { oidcClient } from "better-auth/client/plugins";
|
||||
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
|
||||
import { ssoClient } from "@better-auth/sso/client";
|
||||
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
@@ -41,7 +41,7 @@ export const authClient = createAuthClient({
|
||||
})(),
|
||||
basePath: withBase('/api/auth'), // Explicitly set the base path
|
||||
plugins: [
|
||||
oidcClient(),
|
||||
oauthProviderClient(),
|
||||
ssoClient(),
|
||||
],
|
||||
});
|
||||
|
||||
+97
-11
@@ -1,6 +1,7 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { oidcProvider } from "better-auth/plugins";
|
||||
import { jwt } from "better-auth/plugins";
|
||||
import { oauthProvider } from "@better-auth/oauth-provider";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import { db, users } from "./db";
|
||||
import * as schema from "./db/schema";
|
||||
@@ -74,6 +75,23 @@ export async function resolveTrustedOrigins(request?: Request): Promise<string[]
|
||||
return uniqueOrigins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Better Auth logger level from BETTER_AUTH_LOG_LEVEL.
|
||||
* Returns undefined for unset/invalid values so Better Auth falls back
|
||||
* to its built-in default ("warn"). "success" is intentionally excluded
|
||||
* — it is an output level, not a valid threshold.
|
||||
*/
|
||||
function resolveAuthLogLevel(): "debug" | "info" | "warn" | "error" | undefined {
|
||||
const raw = process.env.BETTER_AUTH_LOG_LEVEL?.trim().toLowerCase();
|
||||
if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") {
|
||||
return raw;
|
||||
}
|
||||
if (raw) {
|
||||
console.warn(`Invalid BETTER_AUTH_LOG_LEVEL: "${raw}", using default ("warn")`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
// Database configuration
|
||||
database: drizzleAdapter(db, {
|
||||
@@ -85,6 +103,17 @@ export const auth = betterAuth({
|
||||
// Secret for signing tokens
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
|
||||
// Logger configuration.
|
||||
//
|
||||
// Better Auth ships its own logger (it does NOT read the `DEBUG` env
|
||||
// var / the `debug` npm package — `DEBUG=better-auth:*` is a no-op).
|
||||
// The default level is "warn", so SSO/OIDC debug and info messages
|
||||
// are hidden out of the box. Set BETTER_AUTH_LOG_LEVEL=debug to surface
|
||||
// the full sign-in / callback trace when troubleshooting SSO.
|
||||
logger: {
|
||||
level: resolveAuthLogLevel(),
|
||||
},
|
||||
|
||||
// Base URL configuration - use the primary URL (Better Auth only supports single baseURL)
|
||||
baseURL: (() => {
|
||||
const url = process.env.BETTER_AUTH_URL;
|
||||
@@ -154,26 +183,71 @@ export const auth = betterAuth({
|
||||
},
|
||||
},
|
||||
|
||||
// Account linking configuration.
|
||||
//
|
||||
// Lets a user who first registered with email/password sign in through SSO
|
||||
// and land on the *same* account, instead of being bounced back to /login.
|
||||
// Better Auth's auto-link path (link-account.mjs) refuses unless BOTH sides
|
||||
// pass:
|
||||
// - upstream: provider is "trusted" (either listed in `trustedProviders`
|
||||
// or the SSO plugin marks it trusted via domainVerified +
|
||||
// domain match) OR userInfo.emailVerified === true
|
||||
// - local: existing user is verified (or requireLocalEmailVerified=false)
|
||||
//
|
||||
// We don't wire an email-verification flow, so the local admin always has
|
||||
// emailVerified=false — `requireLocalEmailVerified: false` is required.
|
||||
//
|
||||
// We deliberately do NOT use the catch-all `trustedProviders` list (which
|
||||
// would blanket-trust every registered IdP). Instead the SSO provider's
|
||||
// own `domainVerified` flag — set to true at registration time, scoped to
|
||||
// the operator-supplied `domain` — gates linking. The SSO plugin enforces
|
||||
// `validateEmailDomain(userInfo.email, provider.domain)` on top of it, so
|
||||
// a sign-in is only auto-linked when (a) the operator vouched for the IdP
|
||||
// by registering it, and (b) the user's email actually belongs to the
|
||||
// domain that was vouched for. Cross-domain claims from a compromised or
|
||||
// permissive IdP do not silently absorb local accounts. (Same-domain
|
||||
// claims still require the operator to trust their IdP's identity model.)
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
requireLocalEmailVerified: false,
|
||||
// Keep the default (false): never link accounts whose emails differ.
|
||||
allowDifferentEmails: false,
|
||||
},
|
||||
},
|
||||
|
||||
// Plugins configuration
|
||||
plugins: [
|
||||
// OIDC Provider plugin - allows this app to act as an OIDC provider
|
||||
oidcProvider({
|
||||
// JWT plugin — provides the JWKS keypair that the OAuth provider uses to
|
||||
// sign OIDC id_tokens with an asymmetric key (what most relying parties
|
||||
// expect). Required by @better-auth/oauth-provider unless disableJwtPlugin
|
||||
// is set. Keys are persisted in the `jwks` table.
|
||||
jwt(),
|
||||
|
||||
// OAuth 2.1 / OIDC Provider — allows this app to act as an identity
|
||||
// provider for other applications. Replaces the deprecated `oidcProvider`
|
||||
// plugin (which Better Auth will remove in a future release). Client and
|
||||
// consent records now live in the oauth_clients / oauth_consents tables.
|
||||
oauthProvider({
|
||||
loginPage: withBase("/login"),
|
||||
consentPage: withBase("/oauth/consent"),
|
||||
// Allow dynamic client registration for flexibility
|
||||
allowDynamicClientRegistration: true,
|
||||
// Note: trustedClients would be configured here if Better Auth supports it
|
||||
// For now, we'll use dynamic registration
|
||||
// Customize user info claims based on scopes
|
||||
getAdditionalUserInfoClaim: (user, scopes) => {
|
||||
// Mirror the old getAdditionalUserInfoClaim: expose our extra `username`
|
||||
// field on both the userinfo endpoint and the id_token when the
|
||||
// "profile" scope is granted.
|
||||
customUserInfoClaims: ({ user, scopes }) => {
|
||||
const claims: Record<string, any> = {};
|
||||
if (scopes.includes("profile")) {
|
||||
claims.username = user.username;
|
||||
}
|
||||
if (scopes.includes("profile")) claims.username = (user as any).username;
|
||||
return claims;
|
||||
},
|
||||
customIdTokenClaims: ({ user, scopes }) => {
|
||||
const claims: Record<string, any> = {};
|
||||
if (scopes.includes("profile")) claims.username = (user as any).username;
|
||||
return claims;
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
// SSO plugin - allows users to authenticate with external OIDC providers
|
||||
sso({
|
||||
// Provision new users when they sign in with SSO
|
||||
@@ -205,6 +279,18 @@ export const auth = betterAuth({
|
||||
disableImplicitSignUp: false,
|
||||
// Trust email_verified claims from the upstream provider so we can link by matching email
|
||||
trustEmailVerified: true,
|
||||
// Surface `domainVerified` to the model so account-linking can read it.
|
||||
//
|
||||
// Better Auth's adapter transformOutput (factory.mjs) only copies fields
|
||||
// that are declared in the plugin's model schema; the SSO plugin only
|
||||
// declares `domainVerified` when this option is enabled. Without it the
|
||||
// column is in the DB but stripped from the returned provider object, so
|
||||
// the trust check `"domainVerified" in provider` is silently false and
|
||||
// sign-ins land on /?error=UNKNOWN. We don't use the DNS-based
|
||||
// verify-domain flow this option also exposes — we set
|
||||
// `domainVerified: true` directly in src/pages/api/auth/sso/register.ts
|
||||
// after the plugin's create, scoped by the operator-supplied `domain`.
|
||||
domainVerification: { enabled: true },
|
||||
}),
|
||||
|
||||
// Header / forward authentication bridge. Exposes
|
||||
|
||||
+4
-2
@@ -123,9 +123,11 @@ export {
|
||||
accounts,
|
||||
verificationTokens,
|
||||
verifications,
|
||||
oauthApplications,
|
||||
oauthClients,
|
||||
oauthAccessTokens,
|
||||
oauthConsent,
|
||||
oauthRefreshTokens,
|
||||
oauthConsents,
|
||||
jwkss,
|
||||
ssoProviders,
|
||||
rateLimits
|
||||
} from "./schema";
|
||||
|
||||
+107
-46
@@ -618,69 +618,121 @@ export const verifications = sqliteTable("verifications", {
|
||||
|
||||
// ===== OIDC Provider Tables =====
|
||||
|
||||
// OAuth Applications table
|
||||
export const oauthApplications = sqliteTable("oauth_applications", {
|
||||
// ===== OAuth 2.1 / OIDC Provider tables (@better-auth/oauth-provider) =====
|
||||
//
|
||||
// These back the OAuth/OIDC *provider* feature (gitea-mirror acting as an
|
||||
// identity provider for other apps). They are managed entirely by Better
|
||||
// Auth's drizzle adapter, so:
|
||||
// - the exported binding name must equal the plugin model name pluralized
|
||||
// under `usePlural: true` (oauthClient -> oauthClients, jwks -> jwkss);
|
||||
// - the object property names must match the plugin field names (camelCase),
|
||||
// while the SQL column names may be snake_case;
|
||||
// - `string[]` and `json` fields are serialized to JSON text by the adapter,
|
||||
// so they are plain `text` columns here.
|
||||
//
|
||||
// Migrated from the deprecated `oidc-provider` plugin (tables
|
||||
// oauth_applications / oauth_access_tokens / oauth_consent). See the
|
||||
// accompanying Drizzle migration for the data-preserving upgrade path.
|
||||
|
||||
// OAuth clients (replaces the old `oauth_applications` table)
|
||||
export const oauthClients = sqliteTable("oauth_clients", {
|
||||
id: text("id").primaryKey(),
|
||||
clientId: text("client_id").notNull().unique(),
|
||||
clientSecret: text("client_secret").notNull(),
|
||||
name: text("name").notNull(),
|
||||
redirectURLs: text("redirect_urls").notNull(), // Comma-separated list
|
||||
metadata: text("metadata"), // JSON string
|
||||
type: text("type").notNull(), // web, mobile, etc
|
||||
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
|
||||
userId: text("user_id"), // Optional - owner of the application
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
clientSecret: text("client_secret"),
|
||||
name: text("name"),
|
||||
disabled: integer("disabled", { mode: "boolean" }).default(false),
|
||||
skipConsent: integer("skip_consent", { mode: "boolean" }),
|
||||
enableEndSession: integer("enable_end_session", { mode: "boolean" }),
|
||||
subjectType: text("subject_type"),
|
||||
scopes: text("scopes"), // JSON string[]
|
||||
userId: text("user_id").references(() => users.id),
|
||||
uri: text("uri"),
|
||||
icon: text("icon"),
|
||||
contacts: text("contacts"), // JSON string[]
|
||||
tos: text("tos"),
|
||||
policy: text("policy"),
|
||||
softwareId: text("software_id"),
|
||||
softwareVersion: text("software_version"),
|
||||
softwareStatement: text("software_statement"),
|
||||
redirectUris: text("redirect_uris").notNull(), // JSON string[]
|
||||
postLogoutRedirectUris: text("post_logout_redirect_uris"), // JSON string[]
|
||||
tokenEndpointAuthMethod: text("token_endpoint_auth_method"),
|
||||
grantTypes: text("grant_types"), // JSON string[]
|
||||
responseTypes: text("response_types"), // JSON string[]
|
||||
public: integer("public", { mode: "boolean" }),
|
||||
type: text("type"),
|
||||
requirePKCE: integer("require_pkce", { mode: "boolean" }),
|
||||
referenceId: text("reference_id"),
|
||||
metadata: text("metadata"), // JSON
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_oauth_applications_client_id").on(table.clientId),
|
||||
index("idx_oauth_applications_user_id").on(table.userId),
|
||||
index("idx_oauth_clients_client_id").on(table.clientId),
|
||||
index("idx_oauth_clients_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// OAuth Access Tokens table
|
||||
// OAuth access tokens
|
||||
export const oauthAccessTokens = sqliteTable("oauth_access_tokens", {
|
||||
id: text("id").primaryKey(),
|
||||
accessToken: text("access_token").notNull(),
|
||||
refreshToken: text("refresh_token"),
|
||||
accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp" }).notNull(),
|
||||
refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp" }),
|
||||
token: text("token").unique(),
|
||||
clientId: text("client_id").notNull(),
|
||||
userId: text("user_id").notNull().references(() => users.id),
|
||||
scopes: text("scopes").notNull(), // Comma-separated list
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
sessionId: text("session_id"),
|
||||
userId: text("user_id").references(() => users.id),
|
||||
referenceId: text("reference_id"),
|
||||
refreshId: text("refresh_id"),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
scopes: text("scopes").notNull(), // JSON string[]
|
||||
}, (table) => [
|
||||
index("idx_oauth_access_tokens_access_token").on(table.accessToken),
|
||||
index("idx_oauth_access_tokens_user_id").on(table.userId),
|
||||
index("idx_oauth_access_tokens_token").on(table.token),
|
||||
index("idx_oauth_access_tokens_client_id").on(table.clientId),
|
||||
index("idx_oauth_access_tokens_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// OAuth Consent table
|
||||
export const oauthConsent = sqliteTable("oauth_consent", {
|
||||
// OAuth refresh tokens (new in the OAuth 2.1 provider)
|
||||
export const oauthRefreshTokens = sqliteTable("oauth_refresh_tokens", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => users.id),
|
||||
token: text("token").notNull().unique(),
|
||||
clientId: text("client_id").notNull(),
|
||||
scopes: text("scopes").notNull(), // Comma-separated list
|
||||
consentGiven: integer("consent_given", { mode: "boolean" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
sessionId: text("session_id"),
|
||||
userId: text("user_id").notNull().references(() => users.id),
|
||||
referenceId: text("reference_id"),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
revoked: integer("revoked", { mode: "timestamp" }),
|
||||
authTime: integer("auth_time", { mode: "timestamp" }),
|
||||
scopes: text("scopes").notNull(), // JSON string[]
|
||||
}, (table) => [
|
||||
index("idx_oauth_consent_user_id").on(table.userId),
|
||||
index("idx_oauth_consent_client_id").on(table.clientId),
|
||||
index("idx_oauth_consent_user_client").on(table.userId, table.clientId),
|
||||
index("idx_oauth_refresh_tokens_token").on(table.token),
|
||||
index("idx_oauth_refresh_tokens_client_id").on(table.clientId),
|
||||
index("idx_oauth_refresh_tokens_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// OAuth consent records
|
||||
export const oauthConsents = sqliteTable("oauth_consents", {
|
||||
id: text("id").primaryKey(),
|
||||
clientId: text("client_id").notNull(),
|
||||
userId: text("user_id").references(() => users.id),
|
||||
referenceId: text("reference_id"),
|
||||
scopes: text("scopes").notNull(), // JSON string[]
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_oauth_consents_client_id").on(table.clientId),
|
||||
index("idx_oauth_consents_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// JWKS keypairs for signing OIDC id_tokens (better-auth `jwt` plugin).
|
||||
// Model name "jwks" pluralizes to the binding name "jwkss" under usePlural,
|
||||
// while the physical table stays "jwks".
|
||||
export const jwkss = sqliteTable("jwks", {
|
||||
id: text("id").primaryKey(),
|
||||
publicKey: text("public_key").notNull(),
|
||||
privateKey: text("private_key").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }),
|
||||
});
|
||||
|
||||
// ===== SSO Provider Tables =====
|
||||
|
||||
// SSO Providers table
|
||||
@@ -689,6 +741,15 @@ export const ssoProviders = sqliteTable("sso_providers", {
|
||||
issuer: text("issuer").notNull(),
|
||||
domain: text("domain").notNull(),
|
||||
oidcConfig: text("oidc_config").notNull(), // JSON string with OIDC configuration
|
||||
// The upgraded @better-auth/sso plugin writes this on every insert (null for OIDC providers).
|
||||
// Drizzle's adapter rejects unknown fields, so the column must exist.
|
||||
samlConfig: text("saml_config"),
|
||||
// Used by the SSO plugin's account-linking trust check: a sign-in is treated
|
||||
// as trusted when this is true AND the user's email domain matches `domain`
|
||||
// above. We set this to true on register (see /api/auth/sso/register.ts) so
|
||||
// domain-scoped auto-linking works out of the box; the column default keeps
|
||||
// existing rows trusted after upgrade.
|
||||
domainVerified: integer("domain_verified", { mode: "boolean" }).notNull().default(true),
|
||||
userId: text("user_id").notNull(), // Admin who created this provider
|
||||
providerId: text("provider_id").notNull().unique(), // Unique identifier for the provider
|
||||
organizationId: text("organization_id"), // Optional - if provider is linked to an organization
|
||||
|
||||
@@ -47,8 +47,11 @@ export async function POST(context: APIContext) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Use Better Auth server API to register OAuth2 application
|
||||
const response = await auth.api.registerOAuthApplication({
|
||||
// Use Better Auth server API to register OAuth2 client (RFC 7591
|
||||
// dynamic client registration via @better-auth/oauth-provider).
|
||||
// Note: jwks / jwks_uri / metadata are not part of the new
|
||||
// registration body and are intentionally omitted.
|
||||
const response = await auth.api.registerOAuthClient({
|
||||
body: {
|
||||
client_name,
|
||||
redirect_uris,
|
||||
@@ -61,9 +64,6 @@ export async function POST(context: APIContext) {
|
||||
contacts,
|
||||
tos_uri,
|
||||
policy_uri,
|
||||
jwks_uri,
|
||||
jwks,
|
||||
metadata,
|
||||
software_id,
|
||||
software_version,
|
||||
software_statement,
|
||||
|
||||
@@ -152,25 +152,14 @@ export async function POST(context: APIContext) {
|
||||
headers.set("cookie", cookieHeader);
|
||||
}
|
||||
|
||||
// Register the SSO provider using Better Auth's API
|
||||
const response = await auth.api.registerSSOProvider({
|
||||
// Register the SSO provider using Better Auth's API.
|
||||
// auth.api.* returns the parsed result directly (not a fetch Response); it
|
||||
// throws APIError on failure, which createSecureErrorResponse handles below.
|
||||
const result = await auth.api.registerSSOProvider({
|
||||
body: registrationBody,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Failed to register SSO provider: ${error}` }),
|
||||
{
|
||||
status: response.status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Mirror provider entry into local SSO table for UI listing
|
||||
try {
|
||||
const existing = await db
|
||||
@@ -183,6 +172,13 @@ export async function POST(context: APIContext) {
|
||||
issuer: registrationBody.issuer,
|
||||
domain: registrationBody.domain,
|
||||
organizationId: registrationBody.organizationId,
|
||||
// Mark this provider as trusted for the `domain` it was registered
|
||||
// under. Better Auth's SSO plugin gates account auto-linking on this
|
||||
// flag together with an email-domain match (validateEmailDomain), so
|
||||
// sign-ins from users outside the registered domain are NOT
|
||||
// auto-linked even though the provider is trusted. The plugin's own
|
||||
// create call hardcodes this to false, so we set it here.
|
||||
domainVerified: true,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
@@ -210,6 +206,7 @@ export async function POST(context: APIContext) {
|
||||
userId: user.id,
|
||||
providerId: registrationBody.providerId,
|
||||
organizationId: registrationBody.organizationId,
|
||||
domainVerified: true,
|
||||
});
|
||||
}
|
||||
} catch (mirroringError) {
|
||||
|
||||
@@ -1,26 +1,61 @@
|
||||
import type { APIContext } from "astro";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
import { requireAuth } from "@/lib/utils/auth-helpers";
|
||||
import { db, oauthApplications } from "@/lib/db";
|
||||
import { nanoid } from "nanoid";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, oauthClients } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { generateRandomString } from "@/lib/utils";
|
||||
|
||||
// GET /api/sso/applications - List all OAuth applications
|
||||
// Backward-compatible OAuth application management API.
|
||||
//
|
||||
// Migrated from the deprecated `oidc-provider` plugin to
|
||||
// `@better-auth/oauth-provider`. Clients now live in the `oauth_clients`
|
||||
// table and are managed by the plugin (which generates and hashes the
|
||||
// client secret), so mutations delegate to `auth.api.*OAuthClient`. Reads
|
||||
// are served straight from the table and mapped back to the legacy response
|
||||
// shape (`redirectURLs` as a comma-separated string) so existing consumers
|
||||
// — notably the consent page — keep working.
|
||||
|
||||
// `redirectUris` is stored by the adapter as a JSON-encoded string[].
|
||||
function parseRedirectUris(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value as string[];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch {
|
||||
// Legacy comma-separated fallback
|
||||
return value.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Map a new oauth_clients row onto the legacy application response shape.
|
||||
function toLegacyApplication(row: typeof oauthClients.$inferSelect) {
|
||||
return {
|
||||
id: row.id,
|
||||
clientId: row.clientId,
|
||||
name: row.name ?? "",
|
||||
redirectURLs: parseRedirectUris(row.redirectUris).join(","),
|
||||
type: row.type ?? "web",
|
||||
disabled: row.disabled ?? false,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
// Never expose the (hashed) client secret in list responses
|
||||
clientSecret: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/sso/applications - List all OAuth clients
|
||||
export async function GET(context: APIContext) {
|
||||
try {
|
||||
const { user, response } = await requireAuth(context);
|
||||
const { response } = await requireAuth(context);
|
||||
if (response) return response;
|
||||
|
||||
const applications = await db.select().from(oauthApplications);
|
||||
const rows = await db.select().from(oauthClients);
|
||||
const sanitized = rows.map(toLegacyApplication);
|
||||
|
||||
// Don't send client secrets in list response
|
||||
const sanitizedApps = applications.map(app => ({
|
||||
...app,
|
||||
clientSecret: undefined,
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify(sanitizedApps), {
|
||||
return new Response(JSON.stringify(sanitized), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
@@ -29,10 +64,10 @@ export async function GET(context: APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/sso/applications - Create a new OAuth application
|
||||
// POST /api/sso/applications - Register a new OAuth client
|
||||
export async function POST(context: APIContext) {
|
||||
try {
|
||||
const { user, response } = await requireAuth(context);
|
||||
const { response } = await requireAuth(context);
|
||||
if (response) return response;
|
||||
|
||||
const body = await context.request.json();
|
||||
@@ -49,27 +84,27 @@ export async function POST(context: APIContext) {
|
||||
);
|
||||
}
|
||||
|
||||
// Generate client credentials
|
||||
const clientId = `client_${generateRandomString(32)}`;
|
||||
const clientSecret = `secret_${generateRandomString(48)}`;
|
||||
const redirect_uris = Array.isArray(redirectURLs)
|
||||
? redirectURLs
|
||||
: String(redirectURLs)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Insert new application
|
||||
const [newApp] = await db
|
||||
.insert(oauthApplications)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
clientId,
|
||||
clientSecret,
|
||||
name,
|
||||
redirectURLs: Array.isArray(redirectURLs) ? redirectURLs.join(",") : redirectURLs,
|
||||
// Delegate to the OAuth provider plugin so the client_id / client_secret
|
||||
// are generated and the secret is stored using the plugin's hashing.
|
||||
const created = await auth.api.createOAuthClient({
|
||||
headers: context.request.headers,
|
||||
body: {
|
||||
client_name: name,
|
||||
redirect_uris,
|
||||
type,
|
||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||
userId: user.id,
|
||||
disabled: false,
|
||||
})
|
||||
.returning();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(newApp), {
|
||||
// The plugin returns RFC-style snake_case fields. Surface them plus the
|
||||
// one-time client_secret (only returned here, never again).
|
||||
return new Response(JSON.stringify(created), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
@@ -78,18 +113,19 @@ export async function POST(context: APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/sso/applications/:id - Update an OAuth application
|
||||
// PUT /api/sso/applications?id=<clientId> - Update an OAuth client
|
||||
export async function PUT(context: APIContext) {
|
||||
try {
|
||||
const { user, response } = await requireAuth(context);
|
||||
const { response } = await requireAuth(context);
|
||||
if (response) return response;
|
||||
|
||||
const url = new URL(context.request.url);
|
||||
const appId = url.pathname.split("/").pop();
|
||||
// Accept the OAuth client_id either from the query string or the path.
|
||||
const clientId = url.searchParams.get("id") || url.pathname.split("/").pop();
|
||||
|
||||
if (!appId) {
|
||||
if (!clientId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Application ID is required" }),
|
||||
JSON.stringify({ error: "Client ID is required" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -98,35 +134,26 @@ export async function PUT(context: APIContext) {
|
||||
}
|
||||
|
||||
const body = await context.request.json();
|
||||
const { name, redirectURLs, disabled, metadata } = body;
|
||||
const { name, redirectURLs, disabled } = body;
|
||||
|
||||
const updateData: any = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
const updateBody: Record<string, unknown> = { client_id: clientId };
|
||||
if (name !== undefined) updateBody.client_name = name;
|
||||
if (disabled !== undefined) updateBody.disabled = disabled;
|
||||
if (redirectURLs !== undefined) {
|
||||
updateData.redirectURLs = Array.isArray(redirectURLs)
|
||||
? redirectURLs.join(",")
|
||||
: redirectURLs;
|
||||
}
|
||||
if (disabled !== undefined) updateData.disabled = disabled;
|
||||
if (metadata !== undefined) updateData.metadata = JSON.stringify(metadata);
|
||||
|
||||
const [updated] = await db
|
||||
.update(oauthApplications)
|
||||
.set({
|
||||
...updateData,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(oauthApplications.id, appId))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return new Response(JSON.stringify({ error: "Application not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
updateBody.redirect_uris = Array.isArray(redirectURLs)
|
||||
? redirectURLs
|
||||
: String(redirectURLs)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ...updated, clientSecret: undefined }), {
|
||||
const updated = await auth.api.updateOAuthClient({
|
||||
headers: context.request.headers,
|
||||
body: updateBody as any,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(updated), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
@@ -135,18 +162,18 @@ export async function PUT(context: APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/sso/applications/:id - Delete an OAuth application
|
||||
// DELETE /api/sso/applications?id=<clientId> - Delete an OAuth client
|
||||
export async function DELETE(context: APIContext) {
|
||||
try {
|
||||
const { user, response } = await requireAuth(context);
|
||||
const { response } = await requireAuth(context);
|
||||
if (response) return response;
|
||||
|
||||
const url = new URL(context.request.url);
|
||||
const appId = url.searchParams.get("id");
|
||||
const clientId = url.searchParams.get("id");
|
||||
|
||||
if (!appId) {
|
||||
if (!clientId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Application ID is required" }),
|
||||
JSON.stringify({ error: "Client ID is required" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -154,18 +181,26 @@ export async function DELETE(context: APIContext) {
|
||||
);
|
||||
}
|
||||
|
||||
const deleted = await db
|
||||
.delete(oauthApplications)
|
||||
.where(eq(oauthApplications.id, appId))
|
||||
.returning();
|
||||
// Ensure the client exists so we can return a 404 (the plugin endpoint
|
||||
// may otherwise succeed silently).
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(oauthClients)
|
||||
.where(eq(oauthClients.clientId, clientId))
|
||||
.limit(1);
|
||||
|
||||
if (deleted.length === 0) {
|
||||
if (existing.length === 0) {
|
||||
return new Response(JSON.stringify({ error: "Application not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
await auth.api.deleteOAuthClient({
|
||||
headers: context.request.headers,
|
||||
body: { client_id: clientId },
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -173,4 +208,4 @@ export async function DELETE(context: APIContext) {
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error, "SSO applications API");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user