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:
ARUNAVO RAY
2026-06-02 11:40:54 +05:30
committed by GitHub
parent 695f0ff005
commit 66e3284898
22 changed files with 5467 additions and 188 deletions
+15 -27
View File
@@ -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);
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -123,9 +123,11 @@ export {
accounts,
verificationTokens,
verifications,
oauthApplications,
oauthClients,
oauthAccessTokens,
oauthConsent,
oauthRefreshTokens,
oauthConsents,
jwkss,
ssoProviders,
rateLimits
} from "./schema";
+107 -46
View File
@@ -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
+5 -5
View File
@@ -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,
+12 -15
View File
@@ -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) {
+109 -74
View File
@@ -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");
}
}
}