Replace next-auth with Better Auth, migrate DB columns to camelCase
- Replace next-auth v5 beta with better-auth v1.6.2 (stable releases)
- Add multi-provider OAuth support with admin UI configuration
- New oauthProviders table with encrypted secrets (AES-256-GCM)
- Env var bootstrap (OAUTH_*) syncs to DB, UI-created providers fully editable
- OAuth provider REST API: GET/POST/PUT/DELETE /api/v1/oauth-providers
- Settings page "Authentication Providers" section for admin management
- Account linking uses new accounts table (multi-provider per user)
- Username plugin for credentials sign-in (replaces email@localhost pattern)
- bcrypt password compatibility (existing hashes work)
- Database-backed sessions via Kysely adapter (bun:sqlite direct)
- Configurable rate limiting via AUTH_RATE_LIMIT_* env vars
- All DB columns migrated from snake_case to camelCase
- All TypeScript types/models migrated to camelCase properties
- Removed casing: "snake_case" from Drizzle config
- Callback URL format: {baseUrl}/api/auth/oauth2/callback/{providerId}
- package-lock.json removed and gitignored (using bun.lock)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
6
src/lib/auth-client.ts
Normal file
6
src/lib/auth-client.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { genericOAuthClient, usernameClient } from "better-auth/client/plugins";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [usernameClient(), genericOAuthClient()],
|
||||
});
|
||||
152
src/lib/auth-server.ts
Normal file
152
src/lib/auth-server.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { genericOAuth, username } from "better-auth/plugins";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import db, { sqlite } from "./db";
|
||||
import * as schema from "./db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { config } from "./config";
|
||||
import { decryptSecret } from "./secret";
|
||||
import type { OAuthProvider } from "./models/oauth-providers";
|
||||
import type { GenericOAuthConfig } from "better-auth/plugins";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let cachedAuth: any = null;
|
||||
let cachedProviders: GenericOAuthConfig[] | null = null;
|
||||
|
||||
function mapOAuthProvider(p: OAuthProvider): GenericOAuthConfig {
|
||||
const cfg: GenericOAuthConfig = {
|
||||
providerId: p.id,
|
||||
clientId: p.clientId,
|
||||
clientSecret: p.clientSecret,
|
||||
scopes: p.scopes ? p.scopes.split(/[\s,]+/).filter(Boolean) : undefined,
|
||||
pkce: true,
|
||||
};
|
||||
if (p.authorizationUrl) cfg.authorizationUrl = p.authorizationUrl;
|
||||
if (p.tokenUrl) cfg.tokenUrl = p.tokenUrl;
|
||||
if (p.userinfoUrl) cfg.userInfoUrl = p.userinfoUrl;
|
||||
if (p.issuer) {
|
||||
cfg.issuer = p.issuer;
|
||||
// Only use discovery when explicit URLs are not provided
|
||||
if (!p.authorizationUrl && !p.tokenUrl) {
|
||||
cfg.discoveryUrl = p.issuer.replace(/\/$/, "") + "/.well-known/openid-configuration";
|
||||
}
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** Whether provider load succeeded at least once */
|
||||
let providersLoadedSuccessfully = false;
|
||||
|
||||
function loadProvidersSync(): GenericOAuthConfig[] {
|
||||
// If we have a successful cache, use it
|
||||
if (cachedProviders !== null && providersLoadedSuccessfully) return cachedProviders;
|
||||
|
||||
// If cache is empty from a failed attempt, retry on every call until it succeeds
|
||||
try {
|
||||
const rows = db.select().from(schema.oauthProviders)
|
||||
.where(eq(schema.oauthProviders.enabled, true)).all();
|
||||
const providers: OAuthProvider[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
clientId: decryptSecret(row.clientId),
|
||||
clientSecret: decryptSecret(row.clientSecret),
|
||||
issuer: row.issuer,
|
||||
authorizationUrl: row.authorizationUrl,
|
||||
tokenUrl: row.tokenUrl,
|
||||
userinfoUrl: row.userinfoUrl,
|
||||
scopes: row.scopes,
|
||||
autoLink: row.autoLink,
|
||||
enabled: row.enabled,
|
||||
source: row.source,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}));
|
||||
cachedProviders = providers.map(mapOAuthProvider);
|
||||
providersLoadedSuccessfully = true;
|
||||
} catch (e) {
|
||||
// DB not ready yet — start with empty, will retry on next getAuth() call
|
||||
if (!cachedProviders) cachedProviders = [];
|
||||
console.warn("[auth-server] Failed to load OAuth providers (will retry):", e);
|
||||
}
|
||||
|
||||
return cachedProviders;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function createAuth(): any {
|
||||
const oauthConfigs = loadProvidersSync();
|
||||
|
||||
return betterAuth({
|
||||
database: sqlite,
|
||||
secret: config.sessionSecret,
|
||||
baseURL: config.baseUrl,
|
||||
basePath: "/api/auth",
|
||||
trustedOrigins: [config.baseUrl],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
advanced: {
|
||||
database: {
|
||||
generateId: "serial",
|
||||
},
|
||||
} as any,
|
||||
rateLimit: {
|
||||
enabled: process.env.AUTH_RATE_LIMIT_ENABLED !== "false",
|
||||
window: Number(process.env.AUTH_RATE_LIMIT_WINDOW ?? 10),
|
||||
max: Number(process.env.AUTH_RATE_LIMIT_MAX ?? 200),
|
||||
},
|
||||
user: {
|
||||
modelName: "users",
|
||||
fields: {
|
||||
image: "avatarUrl",
|
||||
},
|
||||
additionalFields: {
|
||||
role: { type: "string", defaultValue: "user", input: false },
|
||||
status: { type: "string", defaultValue: "active", input: false },
|
||||
provider: { type: "string", defaultValue: "", input: false },
|
||||
subject: { type: "string", defaultValue: "", input: false },
|
||||
},
|
||||
},
|
||||
session: {
|
||||
modelName: "sessions",
|
||||
expiresIn: 7 * 24 * 60 * 60,
|
||||
cookieCache: { enabled: false },
|
||||
},
|
||||
account: { modelName: "accounts" },
|
||||
verification: { modelName: "verifications" },
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
password: {
|
||||
async hash(password: string) {
|
||||
const bcrypt = await import("bcryptjs");
|
||||
return bcrypt.default.hashSync(password, 12);
|
||||
},
|
||||
async verify({ hash, password }: { hash: string; password: string }) {
|
||||
const bcrypt = await import("bcryptjs");
|
||||
return bcrypt.default.compareSync(password, hash);
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
username(),
|
||||
genericOAuth({ config: oauthConfigs }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function getAuth(): ReturnType<typeof betterAuth> {
|
||||
// Rebuild if providers failed to load initially and are now available
|
||||
if (cachedAuth && !providersLoadedSuccessfully) {
|
||||
cachedProviders = null;
|
||||
cachedAuth = null;
|
||||
}
|
||||
if (!cachedAuth) {
|
||||
cachedAuth = createAuth();
|
||||
}
|
||||
return cachedAuth;
|
||||
}
|
||||
|
||||
export function invalidateProviderCache(): void {
|
||||
cachedProviders = null;
|
||||
providersLoadedSuccessfully = false;
|
||||
cachedAuth = null;
|
||||
}
|
||||
494
src/lib/auth.ts
494
src/lib/auth.ts
@@ -1,431 +1,92 @@
|
||||
import NextAuth, { type DefaultSession } from "next-auth";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import type { OAuthConfig } from "next-auth/providers";
|
||||
import bcrypt from "bcryptjs";
|
||||
import db from "./db";
|
||||
import { config } from "./config";
|
||||
import { findUserByProviderSubject, createUser, getUserById } from "./models/user";
|
||||
import { createAuditEvent } from "./models/audit";
|
||||
import { decideLinkingStrategy, createLinkingToken, storeLinkingToken, autoLinkOAuth, linkOAuthAuthenticated } from "./services/account-linking";
|
||||
import { getAuth } from "./auth-server";
|
||||
import { getUserById } from "./models/user";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
role: string;
|
||||
provider?: string;
|
||||
} & DefaultSession["user"];
|
||||
export type Session = {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
provider?: string;
|
||||
image?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current session, optionally from a specific request.
|
||||
*
|
||||
* - `auth()` — uses `headers()` from next/headers (server components, route handlers)
|
||||
* - `auth(req)` — uses request headers (middleware)
|
||||
*
|
||||
* Returns `Session | null`. The user's role is always fetched fresh from the database
|
||||
* so that role changes (e.g. demotion) take effect immediately.
|
||||
*/
|
||||
export async function auth(req?: NextRequest): Promise<Session | null> {
|
||||
const hdrs = req
|
||||
? req.headers
|
||||
: (await import("next/headers")).headers();
|
||||
|
||||
// headers() in Next.js 15+ returns a Promise
|
||||
const resolvedHeaders = hdrs instanceof Promise ? await hdrs : hdrs;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let betterAuthSession: any = null;
|
||||
|
||||
try {
|
||||
betterAuthSession = await getAuth().api.getSession({
|
||||
headers: resolvedHeaders,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
interface User {
|
||||
if (!betterAuthSession?.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baUser = betterAuthSession.user as {
|
||||
id: string | number;
|
||||
name?: string | null;
|
||||
email: string;
|
||||
image?: string | null;
|
||||
role?: string;
|
||||
provider?: string;
|
||||
}
|
||||
}
|
||||
status?: string;
|
||||
avatarUrl?: string | null;
|
||||
subject?: string;
|
||||
};
|
||||
const userId = typeof baUser.id === "string" ? Number(baUser.id) : baUser.id;
|
||||
|
||||
// Credentials provider that checks against hashed passwords in the database
|
||||
function createCredentialsProvider() {
|
||||
return Credentials({
|
||||
id: "credentials",
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
username: { label: "Username", type: "text" },
|
||||
password: { label: "Password", type: "password" }
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const username = credentials?.username ? String(credentials.username).trim() : "";
|
||||
const password = credentials?.password ? String(credentials.password) : "";
|
||||
|
||||
if (!username || !password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Look up user in database by email (constructed from username)
|
||||
const email = `${username}@localhost`;
|
||||
const user = await db.query.users.findFirst({
|
||||
where: (table, operators) => operators.eq(table.email, email)
|
||||
});
|
||||
|
||||
if (!user || user.status !== "active" || !user.passwordHash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify password against hashed password in database
|
||||
const isValidPassword = bcrypt.compareSync(password, user.passwordHash);
|
||||
if (!isValidPassword) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
name: user.name ?? username,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const credentialsProvider = createCredentialsProvider();
|
||||
|
||||
// Create OAuth providers based on configuration
|
||||
function createOAuthProviders(): OAuthConfig<Record<string, unknown>>[] {
|
||||
const providers: OAuthConfig<Record<string, unknown>>[] = [];
|
||||
|
||||
if (
|
||||
config.oauth.enabled &&
|
||||
config.oauth.clientId &&
|
||||
config.oauth.clientSecret
|
||||
) {
|
||||
const oauthProvider: OAuthConfig<Record<string, unknown>> = {
|
||||
id: "oauth2",
|
||||
name: config.oauth.providerName,
|
||||
type: "oidc",
|
||||
clientId: config.oauth.clientId,
|
||||
clientSecret: config.oauth.clientSecret,
|
||||
issuer: config.oauth.issuer ?? undefined,
|
||||
authorization: config.oauth.authorizationUrl ?? undefined,
|
||||
token: config.oauth.tokenUrl ?? undefined,
|
||||
userinfo: config.oauth.userinfoUrl ?? undefined,
|
||||
// PKCE is the default for OIDC; state is added as defence-in-depth
|
||||
checks: ["pkce", "state"],
|
||||
profile(profile) {
|
||||
const sub = typeof profile.sub === "string" ? profile.sub : undefined;
|
||||
const id = typeof profile.id === "string" ? profile.id : undefined;
|
||||
const name = typeof profile.name === "string" ? profile.name : undefined;
|
||||
const preferredUsername =
|
||||
typeof profile.preferred_username === "string" ? profile.preferred_username : undefined;
|
||||
const email = typeof profile.email === "string" ? profile.email : undefined;
|
||||
const picture = typeof profile.picture === "string" ? profile.picture : null;
|
||||
const avatarUrl = typeof profile.avatar_url === "string" ? profile.avatar_url : null;
|
||||
|
||||
return {
|
||||
id: sub ?? id,
|
||||
name: name ?? preferredUsername ?? email,
|
||||
email,
|
||||
image: picture ?? avatarUrl,
|
||||
};
|
||||
},
|
||||
};
|
||||
providers.push(oauthProvider);
|
||||
// Always fetch current role/status from database to reflect changes immediately
|
||||
const currentUser = await getUserById(userId);
|
||||
if (!currentUser || currentUser.status !== "active") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
providers: [credentialsProvider, ...createOAuthProviders()],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
},
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
callbacks: {
|
||||
async signIn({ user, account }) {
|
||||
// Credentials provider - handled by authorize function
|
||||
if (account?.provider === "credentials") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// OAuth provider sign-in
|
||||
if (!account || !user.email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if this is an OAuth linking attempt by checking the database
|
||||
const { pendingOAuthLinks } = await import("./db/schema");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
const { nowIso } = await import("./db");
|
||||
|
||||
// Find ALL non-expired pending links for this provider
|
||||
const allPendingLinks = await db.query.pendingOAuthLinks.findMany({
|
||||
where: (table, operators) =>
|
||||
operators.and(
|
||||
operators.eq(table.provider, account.provider),
|
||||
operators.gt(table.expiresAt, nowIso())
|
||||
)
|
||||
});
|
||||
|
||||
// Security: Match by userId to prevent race condition where User B could
|
||||
// overwrite User A's pending link. We verify by checking which user exists.
|
||||
let pendingLink = null;
|
||||
if (allPendingLinks.length === 1) {
|
||||
// Common case: only one user is linking this provider right now
|
||||
pendingLink = allPendingLinks[0];
|
||||
} else if (allPendingLinks.length > 1) {
|
||||
// Race condition detected: multiple users linking same provider
|
||||
// This shouldn't happen with unique index, but handle gracefully
|
||||
// Find the user whose email matches their stored email
|
||||
for (const link of allPendingLinks) {
|
||||
const existingUser = await getUserById(link.userId);
|
||||
if (existingUser && existingUser.email === link.userEmail) {
|
||||
pendingLink = link;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingLink) {
|
||||
try {
|
||||
const userId = pendingLink.userId;
|
||||
const existingUser = await getUserById(userId);
|
||||
|
||||
if (existingUser) {
|
||||
// Security: Validate OAuth email matches the authenticated user's stored email
|
||||
// This prevents users from linking arbitrary OAuth accounts to their credentials account
|
||||
if (user.email && (
|
||||
existingUser.email !== pendingLink.userEmail ||
|
||||
user.email.toLowerCase() !== pendingLink.userEmail.toLowerCase()
|
||||
)) {
|
||||
console.error(`OAuth linking rejected: user email mismatch. Expected ${pendingLink.userEmail}, got ${existingUser.email} (OAuth provider returned ${user.email})`);
|
||||
|
||||
// Clean up the pending link
|
||||
await db.delete(pendingOAuthLinks).where(eq(pendingOAuthLinks.id, pendingLink.id));
|
||||
|
||||
// Audit log for security event
|
||||
await createAuditEvent({
|
||||
userId: existingUser.id,
|
||||
action: "oauth_link_rejected",
|
||||
entityType: "user",
|
||||
entityId: existingUser.id,
|
||||
summary: `OAuth linking rejected: email mismatch`,
|
||||
data: JSON.stringify({
|
||||
provider: account.provider,
|
||||
expectedEmail: pendingLink.userEmail,
|
||||
actualEmail: existingUser.email
|
||||
})
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// User is already authenticated - auto-link
|
||||
const linked = await linkOAuthAuthenticated(
|
||||
userId,
|
||||
account.provider,
|
||||
account.providerAccountId,
|
||||
user.image
|
||||
);
|
||||
|
||||
if (linked) {
|
||||
// Reload user from database to get updated data
|
||||
const updatedUser = await getUserById(userId);
|
||||
|
||||
if (updatedUser) {
|
||||
user.id = updatedUser.id.toString();
|
||||
user.role = updatedUser.role;
|
||||
user.provider = updatedUser.provider;
|
||||
user.email = updatedUser.email;
|
||||
user.name = updatedUser.name;
|
||||
|
||||
// Delete the pending link
|
||||
await db.delete(pendingOAuthLinks).where(eq(pendingOAuthLinks.id, pendingLink.id));
|
||||
|
||||
// Audit log
|
||||
await createAuditEvent({
|
||||
userId: updatedUser.id,
|
||||
action: "account_linked",
|
||||
entityType: "user",
|
||||
entityId: updatedUser.id,
|
||||
summary: `OAuth account linked via ${config.oauth.providerName}`,
|
||||
data: JSON.stringify({ provider: account.provider, email: user.email })
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error processing pending link:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if OAuth account already exists
|
||||
const existingOAuthUser = await findUserByProviderSubject(
|
||||
account.provider,
|
||||
account.providerAccountId
|
||||
);
|
||||
|
||||
if (existingOAuthUser) {
|
||||
// Existing OAuth user - update user object and allow sign-in
|
||||
user.id = existingOAuthUser.id.toString();
|
||||
user.role = existingOAuthUser.role;
|
||||
user.provider = existingOAuthUser.provider;
|
||||
|
||||
// Audit log
|
||||
await createAuditEvent({
|
||||
userId: existingOAuthUser.id,
|
||||
action: "oauth_signin",
|
||||
entityType: "user",
|
||||
entityId: existingOAuthUser.id,
|
||||
summary: `${existingOAuthUser.name || existingOAuthUser.email || "User"} signed in via ${config.oauth.providerName}`,
|
||||
data: JSON.stringify({ provider: account.provider })
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Determine linking strategy
|
||||
const decision = await decideLinkingStrategy(
|
||||
account.provider,
|
||||
account.providerAccountId,
|
||||
user.email
|
||||
);
|
||||
|
||||
if (decision.action === "auto_link" && decision.userId) {
|
||||
// Auto-link OAuth to existing account without password
|
||||
const linked = await autoLinkOAuth(
|
||||
decision.userId,
|
||||
account.provider,
|
||||
account.providerAccountId,
|
||||
user.image
|
||||
);
|
||||
|
||||
if (linked) {
|
||||
const linkedUser = await getUserById(decision.userId);
|
||||
if (linkedUser) {
|
||||
user.id = linkedUser.id.toString();
|
||||
user.role = linkedUser.role;
|
||||
user.provider = linkedUser.provider;
|
||||
|
||||
// Audit log
|
||||
await createAuditEvent({
|
||||
userId: linkedUser.id,
|
||||
action: "account_linked",
|
||||
entityType: "user",
|
||||
entityId: linkedUser.id,
|
||||
summary: `OAuth account auto-linked via ${config.oauth.providerName}`,
|
||||
data: JSON.stringify({ provider: account.provider, email: user.email })
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (decision.action === "require_manual_link" && decision.userId) {
|
||||
// Email collision - require manual linking with password verification
|
||||
const linkingToken = await createLinkingToken(
|
||||
decision.userId,
|
||||
account.provider,
|
||||
account.providerAccountId,
|
||||
user.email
|
||||
);
|
||||
|
||||
const linkingId = await storeLinkingToken(linkingToken);
|
||||
|
||||
// Redirect to link-account page with opaque ID (not the JWT)
|
||||
throw new Error(`LINKING_REQUIRED:${linkingId}`);
|
||||
}
|
||||
|
||||
// New OAuth user - create account
|
||||
const newUser = await createUser({
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
provider: account.provider,
|
||||
subject: account.providerAccountId,
|
||||
avatar_url: user.image
|
||||
});
|
||||
|
||||
user.id = newUser.id.toString();
|
||||
user.role = newUser.role;
|
||||
user.provider = newUser.provider;
|
||||
|
||||
// Audit log
|
||||
await createAuditEvent({
|
||||
userId: newUser.id,
|
||||
action: "oauth_signup",
|
||||
entityType: "user",
|
||||
entityId: newUser.id,
|
||||
summary: `New user ${user.name || user.email || ""} created via ${config.oauth.providerName}`,
|
||||
data: JSON.stringify({ provider: account.provider, email: user.email })
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
// LINKING_REQUIRED is expected flow — rethrow so NextAuth can redirect
|
||||
if (error instanceof Error && error.message.startsWith("LINKING_REQUIRED:")) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error("OAuth sign-in error:", error);
|
||||
|
||||
// Audit log for failed OAuth attempts
|
||||
try {
|
||||
await createAuditEvent({
|
||||
userId: null,
|
||||
action: "oauth_signin_failed",
|
||||
entityType: "user",
|
||||
entityId: null,
|
||||
summary: `OAuth sign-in failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
data: JSON.stringify({
|
||||
provider: account?.provider,
|
||||
email: user?.email,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
});
|
||||
} catch (auditError) {
|
||||
console.error("Failed to create audit log for OAuth error:", auditError);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
user: {
|
||||
id: String(currentUser.id),
|
||||
email: currentUser.email,
|
||||
name: currentUser.name,
|
||||
role: currentUser.role,
|
||||
provider: currentUser.provider || baUser.provider,
|
||||
image: currentUser.avatarUrl ?? (baUser.avatarUrl as string | null | undefined) ?? null,
|
||||
},
|
||||
async jwt({ token, user, account }) {
|
||||
// On sign in, add user info to token
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.email = user.email;
|
||||
token.role = user.role ?? "user";
|
||||
token.provider = account?.provider ?? user.provider ?? "credentials";
|
||||
token.image = user.image;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
// Add user info from token to session
|
||||
if (session.user && token.id) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.provider = token.provider as string;
|
||||
|
||||
// Always fetch current role from database to reflect
|
||||
// role changes (e.g. demotion) without waiting for JWT expiry
|
||||
const userId = Number(token.id);
|
||||
const currentUser = await getUserById(userId);
|
||||
if (currentUser) {
|
||||
session.user.role = currentUser.role;
|
||||
session.user.image = currentUser.avatar_url ?? (token.image as string | null | undefined);
|
||||
} else {
|
||||
// User deleted from DB — deny access by clearing session
|
||||
session.user.role = token.role as string;
|
||||
session.user.image = token.image as string | null | undefined;
|
||||
}
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
secret: config.sessionSecret,
|
||||
// Only trust Host header when explicitly opted in or when NEXTAUTH_URL
|
||||
// is set (operator has declared the canonical URL, so Host validation is moot).
|
||||
trustHost: !!process.env.NEXTAUTH_TRUST_HOST || !!process.env.NEXTAUTH_URL,
|
||||
basePath: "/api/auth",
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to get the current session on the server.
|
||||
*/
|
||||
export async function getSession() {
|
||||
return await auth();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to require authentication, throwing if not authenticated.
|
||||
* Alias for auth() — get the current session on the server.
|
||||
*/
|
||||
export async function requireUser() {
|
||||
export async function getSession(): Promise<Session | null> {
|
||||
return auth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Require authentication. Redirects to /login if not authenticated.
|
||||
*/
|
||||
export async function requireUser(): Promise<Session> {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
const { redirect } = await import("next/navigation");
|
||||
@@ -435,7 +96,10 @@ export async function requireUser() {
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function requireAdmin() {
|
||||
/**
|
||||
* Require admin privileges. Throws if not authenticated or not admin.
|
||||
*/
|
||||
export async function requireAdmin(): Promise<Session> {
|
||||
const session = await requireUser();
|
||||
if (session.user.role !== "admin") {
|
||||
throw new Error("Administrator privileges required");
|
||||
|
||||
@@ -21,10 +21,10 @@ export function normalizeFingerprint(fp: string): string {
|
||||
* Defined here to avoid importing from models (which pulls in db.ts).
|
||||
*/
|
||||
export type MtlsAccessRuleLike = {
|
||||
path_pattern: string;
|
||||
allowed_role_ids: number[];
|
||||
allowed_cert_ids: number[];
|
||||
deny_all: boolean;
|
||||
pathPattern: string;
|
||||
allowedRoleIds: number[];
|
||||
allowedCertIds: number[];
|
||||
denyAll: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -177,14 +177,14 @@ export function resolveAllowedFingerprints(
|
||||
): Set<string> {
|
||||
const allowed = new Set<string>();
|
||||
|
||||
for (const roleId of rule.allowed_role_ids) {
|
||||
for (const roleId of rule.allowedRoleIds) {
|
||||
const fps = roleFingerprintMap.get(roleId);
|
||||
if (fps) {
|
||||
for (const fp of fps) allowed.add(fp);
|
||||
}
|
||||
}
|
||||
|
||||
for (const certId of rule.allowed_cert_ids) {
|
||||
for (const certId of rule.allowedCertIds) {
|
||||
const fp = certFingerprintMap.get(certId);
|
||||
if (fp) allowed.add(fp);
|
||||
}
|
||||
@@ -229,10 +229,10 @@ export function buildMtlsRbacSubroutes(
|
||||
|
||||
// Rules are already sorted by priority desc, path asc
|
||||
for (const rule of accessRules) {
|
||||
if (rule.deny_all) {
|
||||
if (rule.denyAll) {
|
||||
// Explicit deny: any request matching this path gets 403
|
||||
subroutes.push({
|
||||
match: [{ path: [rule.path_pattern] }],
|
||||
match: [{ path: [rule.pathPattern] }],
|
||||
handle: [{
|
||||
handler: "static_response",
|
||||
status_code: "403",
|
||||
@@ -248,7 +248,7 @@ export function buildMtlsRbacSubroutes(
|
||||
if (allowedFps.size === 0) {
|
||||
// Rule exists but no certs match → deny all for this path
|
||||
subroutes.push({
|
||||
match: [{ path: [rule.path_pattern] }],
|
||||
match: [{ path: [rule.pathPattern] }],
|
||||
handle: [{
|
||||
handler: "static_response",
|
||||
status_code: "403",
|
||||
@@ -262,14 +262,14 @@ export function buildMtlsRbacSubroutes(
|
||||
// Allow route: path + fingerprint CEL match
|
||||
const celExpr = buildFingerprintCelExpression(allowedFps);
|
||||
subroutes.push({
|
||||
match: [{ path: [rule.path_pattern], expression: celExpr }],
|
||||
match: [{ path: [rule.pathPattern], expression: celExpr }],
|
||||
handle: [...baseHandlers, reverseProxyHandler],
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
// Deny route: path matches but fingerprint didn't → 403
|
||||
subroutes.push({
|
||||
match: [{ path: [rule.path_pattern] }],
|
||||
match: [{ path: [rule.pathPattern] }],
|
||||
handle: [{
|
||||
handler: "static_response",
|
||||
status_code: "403",
|
||||
|
||||
130
src/lib/caddy.ts
130
src/lib/caddy.ts
@@ -82,14 +82,14 @@ type ProxyHostRow = {
|
||||
name: string;
|
||||
domains: string;
|
||||
upstreams: string;
|
||||
certificate_id: number | null;
|
||||
access_list_id: number | null;
|
||||
ssl_forced: number;
|
||||
hsts_enabled: number;
|
||||
hsts_subdomains: number;
|
||||
allow_websocket: number;
|
||||
preserve_host_header: number;
|
||||
skip_https_hostname_validation: number;
|
||||
certificateId: number | null;
|
||||
accessListId: number | null;
|
||||
sslForced: number;
|
||||
hstsEnabled: number;
|
||||
hstsSubdomains: number;
|
||||
allowWebsocket: number;
|
||||
preserveHostHeader: number;
|
||||
skipHttpsHostnameValidation: number;
|
||||
meta: string | null;
|
||||
enabled: number;
|
||||
};
|
||||
@@ -216,20 +216,20 @@ type LoadBalancerRouteConfig = {
|
||||
};
|
||||
|
||||
type AccessListEntryRow = {
|
||||
access_list_id: number;
|
||||
accessListId: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
passwordHash: string;
|
||||
};
|
||||
|
||||
type CertificateRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
domain_names: string;
|
||||
certificate_pem: string | null;
|
||||
private_key_pem: string | null;
|
||||
auto_renew: number;
|
||||
provider_options: string | null;
|
||||
domainNames: string;
|
||||
certificatePem: string | null;
|
||||
privateKeyPem: string | null;
|
||||
autoRenew: number;
|
||||
providerOptions: string | null;
|
||||
};
|
||||
|
||||
type CaddyHttpRoute = Record<string, unknown>;
|
||||
@@ -507,15 +507,15 @@ function collectCertificateUsage(rows: ProxyHostRow[], certificates: Map<number,
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle auto-managed certificates (certificate_id is null)
|
||||
if (!row.certificate_id) {
|
||||
// Handle auto-managed certificates (certificateId is null)
|
||||
if (!row.certificateId) {
|
||||
for (const domain of filteredDomains) {
|
||||
autoManagedDomains.add(domain);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const cert = certificates.get(row.certificate_id);
|
||||
const cert = certificates.get(row.certificateId);
|
||||
if (!cert) {
|
||||
continue;
|
||||
}
|
||||
@@ -681,9 +681,9 @@ async function buildProxyRoutes(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Allow hosts with certificate_id = null (Caddy Auto) or with valid certificate IDs
|
||||
const isAutoManaged = !row.certificate_id;
|
||||
const hasValidCertificate = row.certificate_id && tlsReadyCertificates.has(row.certificate_id);
|
||||
// Allow hosts with certificateId = null (Caddy Auto) or with valid certificate IDs
|
||||
const isAutoManaged = !row.certificateId;
|
||||
const hasValidCertificate = row.certificateId && tlsReadyCertificates.has(row.certificateId);
|
||||
|
||||
if (!isAutoManaged && !hasValidCertificate) {
|
||||
continue;
|
||||
@@ -720,11 +720,11 @@ async function buildProxyRoutes(
|
||||
meta.waf
|
||||
);
|
||||
if (effectiveWaf?.enabled && effectiveWaf.mode !== 'Off') {
|
||||
handlers.unshift(buildWafHandler(effectiveWaf, Boolean(row.allow_websocket)));
|
||||
handlers.unshift(buildWafHandler(effectiveWaf, Boolean(row.allowWebsocket)));
|
||||
}
|
||||
|
||||
if (row.hsts_enabled) {
|
||||
const value = row.hsts_subdomains ? "max-age=63072000; includeSubDomains" : "max-age=63072000";
|
||||
if (row.hstsEnabled) {
|
||||
const value = row.hstsSubdomains ? "max-age=63072000; includeSubDomains" : "max-age=63072000";
|
||||
handlers.push({
|
||||
handler: "headers",
|
||||
response: {
|
||||
@@ -735,7 +735,7 @@ async function buildProxyRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
if (row.ssl_forced) {
|
||||
if (row.sslForced) {
|
||||
for (const domainGroup of domainGroups) {
|
||||
hostRoutes.push({
|
||||
match: [
|
||||
@@ -774,8 +774,8 @@ async function buildProxyRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
if (row.access_list_id) {
|
||||
const accounts = accessAccounts.get(row.access_list_id) ?? [];
|
||||
if (row.accessListId) {
|
||||
const accounts = accessAccounts.get(row.accessListId) ?? [];
|
||||
if (accounts.length > 0) {
|
||||
handlers.push({
|
||||
handler: "authentication",
|
||||
@@ -783,7 +783,7 @@ async function buildProxyRoutes(
|
||||
http_basic: {
|
||||
accounts: accounts.map((entry) => ({
|
||||
username: entry.username,
|
||||
password: entry.password_hash
|
||||
password: entry.passwordHash
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -856,7 +856,7 @@ async function buildProxyRoutes(
|
||||
};
|
||||
}
|
||||
|
||||
if (row.preserve_host_header) {
|
||||
if (row.preserveHostHeader) {
|
||||
reverseProxyHandler.headers = {
|
||||
request: {
|
||||
set: {
|
||||
@@ -868,7 +868,7 @@ async function buildProxyRoutes(
|
||||
|
||||
// Configure TLS transport for HTTPS upstreams
|
||||
if (resolvedUpstreams.hasHttpsUpstream) {
|
||||
const tlsTransport: Record<string, unknown> = row.skip_https_hostname_validation
|
||||
const tlsTransport: Record<string, unknown> = row.skipHttpsHostnameValidation
|
||||
? {
|
||||
insecure_skip_verify: true
|
||||
}
|
||||
@@ -1068,8 +1068,8 @@ async function buildProxyRoutes(
|
||||
for (const rule of locationRules) {
|
||||
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
|
||||
rule,
|
||||
Boolean(row.skip_https_hostname_validation),
|
||||
Boolean(row.preserve_host_header)
|
||||
Boolean(row.skipHttpsHostnameValidation),
|
||||
Boolean(row.preserveHostHeader)
|
||||
);
|
||||
if (!safePath) continue;
|
||||
hostRoutes.push({
|
||||
@@ -1104,8 +1104,8 @@ async function buildProxyRoutes(
|
||||
for (const rule of locationRules) {
|
||||
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
|
||||
rule,
|
||||
Boolean(row.skip_https_hostname_validation),
|
||||
Boolean(row.preserve_host_header)
|
||||
Boolean(row.skipHttpsHostnameValidation),
|
||||
Boolean(row.preserveHostHeader)
|
||||
);
|
||||
if (!safePath) continue;
|
||||
hostRoutes.push({
|
||||
@@ -1255,8 +1255,8 @@ async function buildProxyRoutes(
|
||||
for (const rule of locationRules) {
|
||||
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
|
||||
rule,
|
||||
Boolean(row.skip_https_hostname_validation),
|
||||
Boolean(row.preserve_host_header)
|
||||
Boolean(row.skipHttpsHostnameValidation),
|
||||
Boolean(row.preserveHostHeader)
|
||||
);
|
||||
if (!safePath) continue;
|
||||
hostRoutes.push({
|
||||
@@ -1286,8 +1286,8 @@ async function buildProxyRoutes(
|
||||
for (const rule of locationRules) {
|
||||
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
|
||||
rule,
|
||||
Boolean(row.skip_https_hostname_validation),
|
||||
Boolean(row.preserve_host_header)
|
||||
Boolean(row.skipHttpsHostnameValidation),
|
||||
Boolean(row.preserveHostHeader)
|
||||
);
|
||||
if (!safePath) continue;
|
||||
hostRoutes.push({
|
||||
@@ -1318,8 +1318,8 @@ async function buildProxyRoutes(
|
||||
for (const rule of locationRules) {
|
||||
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
|
||||
rule,
|
||||
Boolean(row.skip_https_hostname_validation),
|
||||
Boolean(row.preserve_host_header)
|
||||
Boolean(row.skipHttpsHostnameValidation),
|
||||
Boolean(row.preserveHostHeader)
|
||||
);
|
||||
if (!safePath) continue;
|
||||
hostRoutes.push({
|
||||
@@ -1410,7 +1410,7 @@ function buildTlsConnectionPolicies(
|
||||
}
|
||||
};
|
||||
|
||||
// Add policy for auto-managed domains (certificate_id = null)
|
||||
// Add policy for auto-managed domains (certificateId = null)
|
||||
if (autoManagedDomains.size > 0) {
|
||||
const domains = Array.from(autoManagedDomains);
|
||||
// Split first so mTLS domains always get their own policy, regardless of auth result.
|
||||
@@ -1432,14 +1432,14 @@ function buildTlsConnectionPolicies(
|
||||
}
|
||||
|
||||
if (entry.certificate.type === "imported") {
|
||||
if (!entry.certificate.certificate_pem || !entry.certificate.private_key_pem) {
|
||||
if (!entry.certificate.certificatePem || !entry.certificate.privateKeyPem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect PEMs for tls.certificates.load_pem (inline, no shared filesystem needed)
|
||||
importedCertPems.push({
|
||||
certificate: entry.certificate.certificate_pem.trim(),
|
||||
key: entry.certificate.private_key_pem.trim()
|
||||
certificate: entry.certificate.certificatePem.trim(),
|
||||
key: entry.certificate.privateKeyPem.trim()
|
||||
});
|
||||
|
||||
const mTlsDomains = domains.filter(d => mTlsDomainMap.has(d));
|
||||
@@ -1488,7 +1488,7 @@ async function buildTlsAutomation(
|
||||
options: { acmeEmail?: string; dnsSettings?: DnsSettings | null }
|
||||
) {
|
||||
const managedEntries = Array.from(usage.values()).filter(
|
||||
(entry) => entry.certificate.type === "managed" && Boolean(entry.certificate.auto_renew)
|
||||
(entry) => entry.certificate.type === "managed" && Boolean(entry.certificate.autoRenew)
|
||||
);
|
||||
|
||||
const hasAutoManagedDomains = autoManagedDomains.size > 0;
|
||||
@@ -1517,7 +1517,7 @@ async function buildTlsAutomation(
|
||||
const managedCertificateIds = new Set<number>();
|
||||
const policies: Record<string, unknown>[] = [];
|
||||
|
||||
// Add policy for auto-managed domains (certificate_id = null)
|
||||
// Add policy for auto-managed domains (certificateId = null)
|
||||
if (hasAutoManagedDomains) {
|
||||
for (const subjects of groupHostPatternsByPriority(Array.from(autoManagedDomains))) {
|
||||
const issuer: Record<string, unknown> = {
|
||||
@@ -1894,14 +1894,14 @@ async function buildCaddyDocument() {
|
||||
name: h.name,
|
||||
domains: h.domains,
|
||||
upstreams: h.upstreams,
|
||||
certificate_id: h.certificateId,
|
||||
access_list_id: h.accessListId,
|
||||
ssl_forced: h.sslForced ? 1 : 0,
|
||||
hsts_enabled: h.hstsEnabled ? 1 : 0,
|
||||
hsts_subdomains: h.hstsSubdomains ? 1 : 0,
|
||||
allow_websocket: h.allowWebsocket ? 1 : 0,
|
||||
preserve_host_header: h.preserveHostHeader ? 1 : 0,
|
||||
skip_https_hostname_validation: h.skipHttpsHostnameValidation ? 1 : 0,
|
||||
certificateId: h.certificateId,
|
||||
accessListId: h.accessListId,
|
||||
sslForced: h.sslForced ? 1 : 0,
|
||||
hstsEnabled: h.hstsEnabled ? 1 : 0,
|
||||
hstsSubdomains: h.hstsSubdomains ? 1 : 0,
|
||||
allowWebsocket: h.allowWebsocket ? 1 : 0,
|
||||
preserveHostHeader: h.preserveHostHeader ? 1 : 0,
|
||||
skipHttpsHostnameValidation: h.skipHttpsHostnameValidation ? 1 : 0,
|
||||
meta: h.meta,
|
||||
enabled: h.enabled ? 1 : 0
|
||||
}));
|
||||
@@ -1910,17 +1910,17 @@ async function buildCaddyDocument() {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
type: c.type as "managed" | "imported",
|
||||
domain_names: c.domainNames,
|
||||
certificate_pem: c.certificatePem,
|
||||
private_key_pem: c.privateKeyPem,
|
||||
auto_renew: c.autoRenew ? 1 : 0,
|
||||
provider_options: c.providerOptions
|
||||
domainNames: c.domainNames,
|
||||
certificatePem: c.certificatePem,
|
||||
privateKeyPem: c.privateKeyPem,
|
||||
autoRenew: c.autoRenew ? 1 : 0,
|
||||
providerOptions: c.providerOptions
|
||||
}));
|
||||
|
||||
const accessListEntryRows: AccessListEntryRow[] = accessListEntryRecords.map((entry) => ({
|
||||
access_list_id: entry.accessListId,
|
||||
accessListId: entry.accessListId,
|
||||
username: entry.username,
|
||||
password_hash: entry.passwordHash
|
||||
passwordHash: entry.passwordHash
|
||||
}));
|
||||
|
||||
const certificateMap = new Map(certRowsMapped.map((cert) => [cert.id, cert]));
|
||||
@@ -1933,10 +1933,10 @@ async function buildCaddyDocument() {
|
||||
}, new Map());
|
||||
const cAsWithAnyIssuedCerts = new Set(allIssuedCaCertIds.map(r => r.caCertificateId));
|
||||
const accessMap = accessListEntryRows.reduce<Map<number, AccessListEntryRow[]>>((map, entry) => {
|
||||
if (!map.has(entry.access_list_id)) {
|
||||
map.set(entry.access_list_id, []);
|
||||
if (!map.has(entry.accessListId)) {
|
||||
map.set(entry.accessListId, []);
|
||||
}
|
||||
map.get(entry.access_list_id)!.push(entry);
|
||||
map.get(entry.accessListId)!.push(entry);
|
||||
return map;
|
||||
}, new Map());
|
||||
|
||||
@@ -2187,7 +2187,7 @@ export async function applyCaddyConfig() {
|
||||
const document = await buildCaddyDocument();
|
||||
const payload = JSON.stringify(document);
|
||||
const hash = crypto.createHash("sha256").update(payload).digest("hex");
|
||||
setSetting("caddy_config_hash", { hash, updated_at: nowIso() });
|
||||
setSetting("caddy_config_hash", { hash, updatedAt: nowIso() });
|
||||
|
||||
try {
|
||||
const response = await caddyRequest(`${config.caddyApiUrl}/load`, "POST", payload);
|
||||
|
||||
142
src/lib/db.ts
142
src/lib/db.ts
@@ -1,6 +1,8 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import { eq, ne, and, isNull } from "drizzle-orm";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
|
||||
import * as schema from "./db/schema";
|
||||
@@ -58,7 +60,7 @@ function ensureDirectoryFor(pathname: string) {
|
||||
|
||||
const globalForDrizzle = globalThis as GlobalForDrizzle;
|
||||
|
||||
const sqlite =
|
||||
export const sqlite =
|
||||
globalForDrizzle.__SQLITE_CLIENT__ ??
|
||||
(() => {
|
||||
ensureDirectoryFor(sqlitePath);
|
||||
@@ -70,7 +72,7 @@ if (process.env.NODE_ENV !== "production") {
|
||||
}
|
||||
|
||||
export const db =
|
||||
globalForDrizzle.__DRIZZLE_DB__ ?? drizzle(sqlite, { schema, casing: "snake_case" });
|
||||
globalForDrizzle.__DRIZZLE_DB__ ?? drizzle(sqlite, { schema });
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForDrizzle.__DRIZZLE_DB__ = db;
|
||||
@@ -121,6 +123,142 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration: populate `accounts` table from existing users' provider/subject fields.
|
||||
* Also creates credential accounts for password users and syncs env OAuth providers.
|
||||
* Idempotent — skips if already run (checked via settings flag).
|
||||
*/
|
||||
function runBetterAuthDataMigration() {
|
||||
if (sqlitePath === ":memory:") return;
|
||||
|
||||
const { settings, users, accounts } = schema;
|
||||
|
||||
const flag = db.select().from(settings).where(eq(settings.key, "better_auth_migrated")).get();
|
||||
if (flag) return;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Migrate OAuth users: create account rows from users.provider/subject
|
||||
const oauthUsers = db.select().from(users).where(ne(users.provider, "credentials")).all();
|
||||
for (const user of oauthUsers) {
|
||||
if (!user.provider || !user.subject) continue;
|
||||
const existing = db.select().from(accounts).where(
|
||||
and(eq(accounts.userId, user.id), eq(accounts.providerId, user.provider), eq(accounts.accountId, user.subject))
|
||||
).get();
|
||||
if (!existing) {
|
||||
db.insert(accounts).values({
|
||||
userId: user.id,
|
||||
accountId: user.subject,
|
||||
providerId: user.provider,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate credentials users: create credential account rows
|
||||
const credentialUsers = db.select().from(users).where(eq(users.provider, "credentials")).all();
|
||||
for (const user of credentialUsers) {
|
||||
const existing = db.select().from(accounts).where(
|
||||
and(eq(accounts.userId, user.id), eq(accounts.providerId, "credential"))
|
||||
).get();
|
||||
if (!existing) {
|
||||
db.insert(accounts).values({
|
||||
userId: user.id,
|
||||
accountId: user.id.toString(),
|
||||
providerId: "credential",
|
||||
password: user.passwordHash,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
|
||||
// Populate username field for all users (derived from email prefix)
|
||||
const usersWithoutUsername = db.select().from(users).where(isNull(users.username)).all();
|
||||
for (const user of usersWithoutUsername) {
|
||||
const usernameFromEmail = user.email.split("@")[0] || user.email;
|
||||
db.update(users).set({
|
||||
username: usernameFromEmail.toLowerCase(),
|
||||
displayUsername: usernameFromEmail,
|
||||
}).where(eq(users.id, user.id)).run();
|
||||
}
|
||||
|
||||
db.insert(settings).values({ key: "better_auth_migrated", value: "true", updatedAt: now }).run();
|
||||
console.log("Better Auth data migration complete: populated accounts table");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync OAUTH_* env vars into the oauthProviders table (synchronous).
|
||||
* Uses raw Drizzle queries since this runs at module load time.
|
||||
*/
|
||||
function runEnvProviderSync() {
|
||||
if (sqlitePath === ":memory:") return;
|
||||
|
||||
// Lazy import to avoid circular dependency at module load
|
||||
let config: { oauth: { enabled: boolean; providerName: string; clientId: string | null; clientSecret: string | null; issuer: string | null; authorizationUrl: string | null; tokenUrl: string | null; userinfoUrl: string | null; allowAutoLinking: boolean } };
|
||||
try {
|
||||
config = require("./config").config;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.oauth.enabled || !config.oauth.clientId || !config.oauth.clientSecret) return;
|
||||
|
||||
const { oauthProviders } = schema;
|
||||
let encryptSecret: (v: string) => string;
|
||||
try {
|
||||
encryptSecret = require("./secret").encryptSecret;
|
||||
} catch {
|
||||
encryptSecret = (v) => v;
|
||||
}
|
||||
|
||||
const name = config.oauth.providerName;
|
||||
// Use a slug-based ID so the OAuth callback URL is predictable
|
||||
const providerId = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "oauth";
|
||||
const existing = db.select().from(oauthProviders).where(eq(oauthProviders.name, name)).get();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
if (existing && existing.source === "env") {
|
||||
db.update(oauthProviders).set({
|
||||
clientId: encryptSecret(config.oauth.clientId),
|
||||
clientSecret: encryptSecret(config.oauth.clientSecret),
|
||||
issuer: config.oauth.issuer ?? null,
|
||||
authorizationUrl: config.oauth.authorizationUrl ?? null,
|
||||
tokenUrl: config.oauth.tokenUrl ?? null,
|
||||
userinfoUrl: config.oauth.userinfoUrl ?? null,
|
||||
autoLink: config.oauth.allowAutoLinking,
|
||||
updatedAt: now,
|
||||
}).where(eq(oauthProviders.id, existing.id)).run();
|
||||
} else if (!existing) {
|
||||
db.insert(oauthProviders).values({
|
||||
id: providerId,
|
||||
name,
|
||||
type: "oidc",
|
||||
clientId: encryptSecret(config.oauth.clientId),
|
||||
clientSecret: encryptSecret(config.oauth.clientSecret),
|
||||
issuer: config.oauth.issuer ?? null,
|
||||
authorizationUrl: config.oauth.authorizationUrl ?? null,
|
||||
tokenUrl: config.oauth.tokenUrl ?? null,
|
||||
userinfoUrl: config.oauth.userinfoUrl ?? null,
|
||||
scopes: "openid email profile",
|
||||
autoLink: config.oauth.allowAutoLinking,
|
||||
enabled: true,
|
||||
source: "env",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run();
|
||||
console.log(`Synced OAuth provider from env: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runBetterAuthDataMigration();
|
||||
runEnvProviderSync();
|
||||
} catch (error) {
|
||||
console.warn("Better Auth data migration warning:", error);
|
||||
}
|
||||
|
||||
export { schema };
|
||||
export default db;
|
||||
|
||||
|
||||
@@ -6,34 +6,102 @@ export const users = sqliteTable(
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
email: text("email").notNull(),
|
||||
name: text("name"),
|
||||
passwordHash: text("password_hash"),
|
||||
passwordHash: text("passwordHash"),
|
||||
role: text("role").notNull().default("user"),
|
||||
provider: text("provider").notNull(),
|
||||
subject: text("subject").notNull(),
|
||||
avatarUrl: text("avatar_url"),
|
||||
provider: text("provider"),
|
||||
subject: text("subject"),
|
||||
avatarUrl: text("avatarUrl"),
|
||||
status: text("status").notNull().default("active"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
username: text("username"),
|
||||
displayUsername: text("displayUsername"),
|
||||
emailVerified: integer("emailVerified", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
emailUnique: uniqueIndex("users_email_unique").on(table.email),
|
||||
providerSubjectIdx: uniqueIndex("users_provider_subject_idx").on(table.provider, table.subject)
|
||||
emailUnique: uniqueIndex("users_email_unique").on(table.email)
|
||||
})
|
||||
);
|
||||
|
||||
// Auth tables use camelCase DB columns to match Better Auth's Kysely adapter.
|
||||
export const sessions = sqliteTable(
|
||||
"sessions",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id")
|
||||
userId: integer("userId")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
token: text("token").notNull(),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
createdAt: text("created_at").notNull()
|
||||
expiresAt: text("expiresAt").notNull(),
|
||||
ipAddress: text("ipAddress"),
|
||||
userAgent: text("userAgent"),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
tokenUnique: uniqueIndex("sessions_token_unique").on(table.token)
|
||||
tokenUnique: uniqueIndex("sessions_token_unique").on(table.token),
|
||||
userIdx: index("sessions_user_idx").on(table.userId)
|
||||
})
|
||||
);
|
||||
|
||||
export const accounts = sqliteTable(
|
||||
"accounts",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("userId")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
accountId: text("accountId").notNull(),
|
||||
providerId: text("providerId").notNull(),
|
||||
accessToken: text("accessToken"),
|
||||
refreshToken: text("refreshToken"),
|
||||
idToken: text("idToken"),
|
||||
accessTokenExpiresAt: text("accessTokenExpiresAt"),
|
||||
refreshTokenExpiresAt: text("refreshTokenExpiresAt"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
providerAccountIdx: uniqueIndex("accounts_provider_account_idx").on(table.providerId, table.accountId),
|
||||
userIdx: index("accounts_user_idx").on(table.userId)
|
||||
})
|
||||
);
|
||||
|
||||
export const verifications = sqliteTable(
|
||||
"verifications",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: text("expiresAt").notNull(),
|
||||
createdAt: text("createdAt"),
|
||||
updatedAt: text("updatedAt")
|
||||
}
|
||||
);
|
||||
|
||||
export const oauthProviders = sqliteTable(
|
||||
"oauth_providers",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull().default("oidc"),
|
||||
clientId: text("clientId").notNull(),
|
||||
clientSecret: text("clientSecret").notNull(),
|
||||
issuer: text("issuer"),
|
||||
authorizationUrl: text("authorizationUrl"),
|
||||
tokenUrl: text("tokenUrl"),
|
||||
userinfoUrl: text("userinfoUrl"),
|
||||
scopes: text("scopes").notNull().default("openid email profile"),
|
||||
autoLink: integer("autoLink", { mode: "boolean" }).notNull().default(false),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
source: text("source").notNull().default("ui"),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
nameUnique: uniqueIndex("oauth_providers_name_unique").on(table.name)
|
||||
})
|
||||
);
|
||||
|
||||
@@ -42,10 +110,10 @@ export const oauthStates = sqliteTable(
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
state: text("state").notNull(),
|
||||
codeVerifier: text("code_verifier").notNull(),
|
||||
redirectTo: text("redirect_to"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
expiresAt: text("expires_at").notNull()
|
||||
codeVerifier: text("codeVerifier").notNull(),
|
||||
redirectTo: text("redirectTo"),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
expiresAt: text("expiresAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
stateUnique: uniqueIndex("oauth_state_unique").on(table.state)
|
||||
@@ -54,11 +122,11 @@ export const oauthStates = sqliteTable(
|
||||
|
||||
export const pendingOAuthLinks = sqliteTable("pending_oauth_links", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
userId: integer("userId").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
provider: text("provider", { length: 50 }).notNull(),
|
||||
userEmail: text("user_email").notNull(), // Email of the user who initiated linking
|
||||
createdAt: text("created_at").notNull(),
|
||||
expiresAt: text("expires_at").notNull()
|
||||
userEmail: text("userEmail").notNull(), // Email of the user who initiated linking
|
||||
createdAt: text("createdAt").notNull(),
|
||||
expiresAt: text("expiresAt").notNull()
|
||||
}, (table) => ({
|
||||
// Ensure only one pending link per user per provider (prevents race conditions)
|
||||
userProviderUnique: uniqueIndex("pending_oauth_user_provider_unique").on(table.userId, table.provider)
|
||||
@@ -67,7 +135,7 @@ export const pendingOAuthLinks = sqliteTable("pending_oauth_links", {
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
});
|
||||
|
||||
export const instances = sqliteTable(
|
||||
@@ -75,13 +143,13 @@ export const instances = sqliteTable(
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
baseUrl: text("base_url").notNull(),
|
||||
apiToken: text("api_token").notNull(),
|
||||
baseUrl: text("baseUrl").notNull(),
|
||||
apiToken: text("apiToken").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
lastSyncAt: text("last_sync_at"),
|
||||
lastSyncError: text("last_sync_error"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
lastSyncAt: text("lastSyncAt"),
|
||||
lastSyncError: text("lastSyncError"),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
baseUrlUnique: uniqueIndex("instances_base_url_unique").on(table.baseUrl)
|
||||
@@ -92,22 +160,22 @@ export const accessLists = sqliteTable("access_lists", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
});
|
||||
|
||||
export const accessListEntries = sqliteTable(
|
||||
"access_list_entries",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
accessListId: integer("access_list_id")
|
||||
accessListId: integer("accessListId")
|
||||
.references(() => accessLists.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
passwordHash: text("passwordHash").notNull(),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
accessListIdIdx: index("access_list_entries_list_idx").on(table.accessListId)
|
||||
@@ -118,43 +186,43 @@ export const certificates = sqliteTable("certificates", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull(),
|
||||
domainNames: text("domain_names").notNull(),
|
||||
autoRenew: integer("auto_renew", { mode: "boolean" }).notNull().default(true),
|
||||
providerOptions: text("provider_options"),
|
||||
certificatePem: text("certificate_pem"),
|
||||
privateKeyPem: text("private_key_pem"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
domainNames: text("domainNames").notNull(),
|
||||
autoRenew: integer("autoRenew", { mode: "boolean" }).notNull().default(true),
|
||||
providerOptions: text("providerOptions"),
|
||||
certificatePem: text("certificatePem"),
|
||||
privateKeyPem: text("privateKeyPem"),
|
||||
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
});
|
||||
|
||||
export const caCertificates = sqliteTable("ca_certificates", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
certificatePem: text("certificate_pem").notNull(),
|
||||
privateKeyPem: text("private_key_pem"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
certificatePem: text("certificatePem").notNull(),
|
||||
privateKeyPem: text("privateKeyPem"),
|
||||
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
});
|
||||
|
||||
export const issuedClientCertificates = sqliteTable(
|
||||
"issued_client_certificates",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
caCertificateId: integer("ca_certificate_id")
|
||||
caCertificateId: integer("caCertificateId")
|
||||
.references(() => caCertificates.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
commonName: text("common_name").notNull(),
|
||||
serialNumber: text("serial_number").notNull(),
|
||||
fingerprintSha256: text("fingerprint_sha256").notNull(),
|
||||
certificatePem: text("certificate_pem").notNull(),
|
||||
validFrom: text("valid_from").notNull(),
|
||||
validTo: text("valid_to").notNull(),
|
||||
revokedAt: text("revoked_at"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
commonName: text("commonName").notNull(),
|
||||
serialNumber: text("serialNumber").notNull(),
|
||||
fingerprintSha256: text("fingerprintSha256").notNull(),
|
||||
certificatePem: text("certificatePem").notNull(),
|
||||
validFrom: text("validFrom").notNull(),
|
||||
validTo: text("validTo").notNull(),
|
||||
revokedAt: text("revokedAt"),
|
||||
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
caCertificateIdx: index("issued_client_certificates_ca_idx").on(table.caCertificateId),
|
||||
@@ -167,19 +235,19 @@ export const proxyHosts = sqliteTable("proxy_hosts", {
|
||||
name: text("name").notNull(),
|
||||
domains: text("domains").notNull(),
|
||||
upstreams: text("upstreams").notNull(),
|
||||
certificateId: integer("certificate_id").references(() => certificates.id, { onDelete: "set null" }),
|
||||
accessListId: integer("access_list_id").references(() => accessLists.id, { onDelete: "set null" }),
|
||||
ownerUserId: integer("owner_user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
sslForced: integer("ssl_forced", { mode: "boolean" }).notNull().default(true),
|
||||
hstsEnabled: integer("hsts_enabled", { mode: "boolean" }).notNull().default(true),
|
||||
hstsSubdomains: integer("hsts_subdomains", { mode: "boolean" }).notNull().default(false),
|
||||
allowWebsocket: integer("allow_websocket", { mode: "boolean" }).notNull().default(true),
|
||||
preserveHostHeader: integer("preserve_host_header", { mode: "boolean" }).notNull().default(true),
|
||||
certificateId: integer("certificateId").references(() => certificates.id, { onDelete: "set null" }),
|
||||
accessListId: integer("accessListId").references(() => accessLists.id, { onDelete: "set null" }),
|
||||
ownerUserId: integer("ownerUserId").references(() => users.id, { onDelete: "set null" }),
|
||||
sslForced: integer("sslForced", { mode: "boolean" }).notNull().default(true),
|
||||
hstsEnabled: integer("hstsEnabled", { mode: "boolean" }).notNull().default(true),
|
||||
hstsSubdomains: integer("hstsSubdomains", { mode: "boolean" }).notNull().default(false),
|
||||
allowWebsocket: integer("allowWebsocket", { mode: "boolean" }).notNull().default(true),
|
||||
preserveHostHeader: integer("preserveHostHeader", { mode: "boolean" }).notNull().default(true),
|
||||
meta: text("meta"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
skipHttpsHostnameValidation: integer("skip_https_hostname_validation", { mode: "boolean" })
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull(),
|
||||
skipHttpsHostnameValidation: integer("skipHttpsHostnameValidation", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false)
|
||||
});
|
||||
@@ -189,13 +257,13 @@ export const apiTokens = sqliteTable(
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
createdBy: integer("created_by")
|
||||
tokenHash: text("tokenHash").notNull(),
|
||||
createdBy: integer("createdBy")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
expiresAt: text("expires_at")
|
||||
createdAt: text("createdAt").notNull(),
|
||||
lastUsedAt: text("lastUsedAt"),
|
||||
expiresAt: text("expiresAt")
|
||||
},
|
||||
(table) => ({
|
||||
tokenHashUnique: uniqueIndex("api_tokens_token_hash_unique").on(table.tokenHash)
|
||||
@@ -204,20 +272,20 @@ export const apiTokens = sqliteTable(
|
||||
|
||||
export const auditEvents = sqliteTable("audit_events", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
userId: integer("userId").references(() => users.id, { onDelete: "set null" }),
|
||||
action: text("action").notNull(),
|
||||
entityType: text("entity_type").notNull(),
|
||||
entityId: integer("entity_id"),
|
||||
entityType: text("entityType").notNull(),
|
||||
entityId: integer("entityId"),
|
||||
summary: text("summary"),
|
||||
data: text("data"),
|
||||
createdAt: text("created_at").notNull()
|
||||
createdAt: text("createdAt").notNull()
|
||||
});
|
||||
|
||||
export const linkingTokens = sqliteTable("linking_tokens", {
|
||||
id: text("id").primaryKey(),
|
||||
token: text("token").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
expiresAt: text("expires_at").notNull()
|
||||
createdAt: text("createdAt").notNull(),
|
||||
expiresAt: text("expiresAt").notNull()
|
||||
});
|
||||
|
||||
// traffic_events and waf_events have been migrated to ClickHouse.
|
||||
@@ -241,9 +309,9 @@ export const mtlsRoles = sqliteTable(
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
nameUnique: uniqueIndex("mtls_roles_name_unique").on(table.name)
|
||||
@@ -254,13 +322,13 @@ export const mtlsCertificateRoles = sqliteTable(
|
||||
"mtls_certificate_roles",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
issuedClientCertificateId: integer("issued_client_certificate_id")
|
||||
issuedClientCertificateId: integer("issuedClientCertificateId")
|
||||
.references(() => issuedClientCertificates.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
mtlsRoleId: integer("mtls_role_id")
|
||||
mtlsRoleId: integer("mtlsRoleId")
|
||||
.references(() => mtlsRoles.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
createdAt: text("created_at").notNull()
|
||||
createdAt: text("createdAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
certRoleUnique: uniqueIndex("mtls_cert_role_unique").on(
|
||||
@@ -275,18 +343,18 @@ export const mtlsAccessRules = sqliteTable(
|
||||
"mtls_access_rules",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
proxyHostId: integer("proxy_host_id")
|
||||
proxyHostId: integer("proxyHostId")
|
||||
.references(() => proxyHosts.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
pathPattern: text("path_pattern").notNull(),
|
||||
allowedRoleIds: text("allowed_role_ids").notNull().default("[]"),
|
||||
allowedCertIds: text("allowed_cert_ids").notNull().default("[]"),
|
||||
denyAll: integer("deny_all", { mode: "boolean" }).notNull().default(false),
|
||||
pathPattern: text("pathPattern").notNull(),
|
||||
allowedRoleIds: text("allowedRoleIds").notNull().default("[]"),
|
||||
allowedCertIds: text("allowedCertIds").notNull().default("[]"),
|
||||
denyAll: integer("denyAll", { mode: "boolean" }).notNull().default(false),
|
||||
priority: integer("priority").notNull().default(0),
|
||||
description: text("description"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
proxyHostIdx: index("mtls_access_rules_proxy_host_idx").on(table.proxyHostId),
|
||||
@@ -305,9 +373,9 @@ export const groups = sqliteTable(
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
nameUnique: uniqueIndex("groups_name_unique").on(table.name)
|
||||
@@ -318,13 +386,13 @@ export const groupMembers = sqliteTable(
|
||||
"group_members",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
groupId: integer("group_id")
|
||||
groupId: integer("groupId")
|
||||
.references(() => groups.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
userId: integer("user_id")
|
||||
userId: integer("userId")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
createdAt: text("created_at").notNull()
|
||||
createdAt: text("createdAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
memberUnique: uniqueIndex("group_members_unique").on(table.groupId, table.userId),
|
||||
@@ -336,12 +404,12 @@ export const forwardAuthAccess = sqliteTable(
|
||||
"forward_auth_access",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
proxyHostId: integer("proxy_host_id")
|
||||
proxyHostId: integer("proxyHostId")
|
||||
.references(() => proxyHosts.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
userId: integer("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||
groupId: integer("group_id").references(() => groups.id, { onDelete: "cascade" }),
|
||||
createdAt: text("created_at").notNull()
|
||||
userId: integer("userId").references(() => users.id, { onDelete: "cascade" }),
|
||||
groupId: integer("groupId").references(() => groups.id, { onDelete: "cascade" }),
|
||||
createdAt: text("createdAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
hostIdx: index("faa_host_idx").on(table.proxyHostId),
|
||||
@@ -354,12 +422,12 @@ export const forwardAuthSessions = sqliteTable(
|
||||
"forward_auth_sessions",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id")
|
||||
userId: integer("userId")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
createdAt: text("created_at").notNull()
|
||||
tokenHash: text("tokenHash").notNull(),
|
||||
expiresAt: text("expiresAt").notNull(),
|
||||
createdAt: text("createdAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
tokenHashUnique: uniqueIndex("fas_token_hash_unique").on(table.tokenHash),
|
||||
@@ -372,15 +440,15 @@ export const forwardAuthExchanges = sqliteTable(
|
||||
"forward_auth_exchanges",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
sessionId: integer("session_id")
|
||||
sessionId: integer("sessionId")
|
||||
.references(() => forwardAuthSessions.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
codeHash: text("code_hash").notNull(),
|
||||
sessionToken: text("session_token").notNull(), // raw session token (short-lived, single-use)
|
||||
redirectUri: text("redirect_uri").notNull(),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
codeHash: text("codeHash").notNull(),
|
||||
sessionToken: text("sessionToken").notNull(), // raw session token (short-lived, single-use)
|
||||
redirectUri: text("redirectUri").notNull(),
|
||||
expiresAt: text("expiresAt").notNull(),
|
||||
used: integer("used", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: text("created_at").notNull()
|
||||
createdAt: text("createdAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
codeHashUnique: uniqueIndex("fae_code_hash_unique").on(table.codeHash)
|
||||
@@ -391,11 +459,11 @@ export const forwardAuthRedirectIntents = sqliteTable(
|
||||
"forward_auth_redirect_intents",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
ridHash: text("rid_hash").notNull(),
|
||||
redirectUri: text("redirect_uri").notNull(),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
ridHash: text("ridHash").notNull(),
|
||||
redirectUri: text("redirectUri").notNull(),
|
||||
expiresAt: text("expiresAt").notNull(),
|
||||
consumed: integer("consumed", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: text("created_at").notNull()
|
||||
createdAt: text("createdAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
ridHashUnique: uniqueIndex("fari_rid_hash_unique").on(table.ridHash),
|
||||
@@ -409,16 +477,16 @@ export const l4ProxyHosts = sqliteTable("l4_proxy_hosts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
protocol: text("protocol").notNull(),
|
||||
listenAddress: text("listen_address").notNull(),
|
||||
listenAddress: text("listenAddress").notNull(),
|
||||
upstreams: text("upstreams").notNull(),
|
||||
matcherType: text("matcher_type").notNull().default("none"),
|
||||
matcherValue: text("matcher_value"),
|
||||
tlsTermination: integer("tls_termination", { mode: "boolean" }).notNull().default(false),
|
||||
proxyProtocolVersion: text("proxy_protocol_version"),
|
||||
proxyProtocolReceive: integer("proxy_protocol_receive", { mode: "boolean" }).notNull().default(false),
|
||||
ownerUserId: integer("owner_user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
matcherType: text("matcherType").notNull().default("none"),
|
||||
matcherValue: text("matcherValue"),
|
||||
tlsTermination: integer("tlsTermination", { mode: "boolean" }).notNull().default(false),
|
||||
proxyProtocolVersion: text("proxyProtocolVersion"),
|
||||
proxyProtocolReceive: integer("proxyProtocolReceive", { mode: "boolean" }).notNull().default(false),
|
||||
ownerUserId: integer("ownerUserId").references(() => users.id, { onDelete: "set null" }),
|
||||
meta: text("meta"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull(),
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import db, { nowIso } from "./db";
|
||||
import { config } from "./config";
|
||||
import { users } from "./db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { users, accounts } from "./db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Ensures the admin user from environment variables exists in the database.
|
||||
@@ -37,9 +38,13 @@ export async function ensureAdminUser(): Promise<void> {
|
||||
subject,
|
||||
passwordHash,
|
||||
role: "admin",
|
||||
username: config.adminUsername.toLowerCase(),
|
||||
displayUsername: config.adminUsername,
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(users.id, adminId));
|
||||
// Ensure credential account row exists for Better Auth
|
||||
await ensureCredentialAccount(adminId, passwordHash);
|
||||
console.log(`Updated admin user: ${config.adminUsername}`);
|
||||
return;
|
||||
}
|
||||
@@ -54,6 +59,8 @@ export async function ensureAdminUser(): Promise<void> {
|
||||
role: "admin",
|
||||
provider,
|
||||
subject,
|
||||
username: config.adminUsername.toLowerCase(),
|
||||
displayUsername: config.adminUsername,
|
||||
avatarUrl: null,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
@@ -61,4 +68,35 @@ export async function ensureAdminUser(): Promise<void> {
|
||||
});
|
||||
|
||||
console.log(`Created admin user: ${config.adminUsername}`);
|
||||
|
||||
// Ensure credential account row exists for Better Auth
|
||||
await ensureCredentialAccount(adminId, passwordHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a credential account row exists in the accounts table for Better Auth.
|
||||
* Better Auth requires an accounts row with providerId="credential" and the password hash.
|
||||
*/
|
||||
async function ensureCredentialAccount(userId: number, passwordHash: string): Promise<void> {
|
||||
const now = nowIso();
|
||||
const existing = await db.select().from(accounts).where(
|
||||
and(eq(accounts.userId, userId), eq(accounts.providerId, "credential"))
|
||||
).get();
|
||||
|
||||
if (existing) {
|
||||
// Update password hash if changed
|
||||
await db.update(accounts).set({
|
||||
password: passwordHash,
|
||||
updatedAt: now,
|
||||
}).where(eq(accounts.id, existing.id));
|
||||
} else {
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
accountId: userId.toString(),
|
||||
providerId: "credential",
|
||||
password: passwordHash,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import { asc, eq, inArray, count } from "drizzle-orm";
|
||||
export type AccessListEntry = {
|
||||
id: number;
|
||||
username: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AccessList = {
|
||||
@@ -17,8 +17,8 @@ export type AccessList = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
entries: AccessListEntry[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AccessListInput = {
|
||||
@@ -34,8 +34,8 @@ function buildEntry(row: AccessListEntryRow): AccessListEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,8 +48,8 @@ function toAccessList(row: AccessListRow, entries: AccessListEntryRow[]): Access
|
||||
.slice()
|
||||
.sort((a, b) => a.username.localeCompare(b.username))
|
||||
.map(buildEntry),
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import { NotFoundError } from "../api-auth";
|
||||
export type ApiToken = {
|
||||
id: number;
|
||||
name: string;
|
||||
created_by: number;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
expires_at: string | null;
|
||||
createdBy: number;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
type ApiTokenRow = typeof apiTokens.$inferSelect;
|
||||
@@ -19,10 +19,10 @@ function toApiToken(row: ApiTokenRow): ApiToken {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
created_by: row.createdBy,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
last_used_at: row.lastUsedAt ? toIso(row.lastUsedAt) : null,
|
||||
expires_at: row.expiresAt ? toIso(row.expiresAt) : null,
|
||||
createdBy: row.createdBy,
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
lastUsedAt: row.lastUsedAt ? toIso(row.lastUsedAt) : null,
|
||||
expiresAt: row.expiresAt ? toIso(row.expiresAt) : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ import { desc, like, or, count } from "drizzle-orm";
|
||||
|
||||
export type AuditEvent = {
|
||||
id: number;
|
||||
user_id: number | null;
|
||||
userId: number | null;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id: number | null;
|
||||
entityType: string;
|
||||
entityId: number | null;
|
||||
summary: string | null;
|
||||
created_at: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
// Escape LIKE metacharacters so user input is treated as literal text
|
||||
@@ -57,12 +57,12 @@ export async function listAuditEvents(
|
||||
|
||||
return events.map((event) => ({
|
||||
id: event.id,
|
||||
user_id: event.userId,
|
||||
userId: event.userId,
|
||||
action: event.action,
|
||||
entity_type: event.entityType,
|
||||
entity_id: event.entityId,
|
||||
entityType: event.entityType,
|
||||
entityId: event.entityId,
|
||||
summary: event.summary,
|
||||
created_at: toIso(event.createdAt)!,
|
||||
createdAt: toIso(event.createdAt)!,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -16,16 +16,16 @@ function tryParseJson<T>(value: string | null | undefined, fallback: T): T {
|
||||
export type CaCertificate = {
|
||||
id: number;
|
||||
name: string;
|
||||
certificate_pem: string;
|
||||
has_private_key: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
certificatePem: string;
|
||||
hasPrivateKey: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CaCertificateInput = {
|
||||
name: string;
|
||||
certificate_pem: string;
|
||||
private_key_pem?: string;
|
||||
certificatePem: string;
|
||||
privateKeyPem?: string;
|
||||
};
|
||||
|
||||
type CaCertificateRow = typeof caCertificates.$inferSelect;
|
||||
@@ -34,10 +34,10 @@ function parseCaCertificate(row: CaCertificateRow): CaCertificate {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
certificate_pem: row.certificatePem,
|
||||
has_private_key: !!row.privateKeyPem,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
certificatePem: row.certificatePem,
|
||||
hasPrivateKey: !!row.privateKeyPem,
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,8 +66,8 @@ export async function createCaCertificate(input: CaCertificateInput, actorUserId
|
||||
.insert(caCertificates)
|
||||
.values({
|
||||
name: input.name.trim(),
|
||||
certificatePem: input.certificate_pem.trim(),
|
||||
privateKeyPem: input.private_key_pem?.trim() ?? null,
|
||||
certificatePem: input.certificatePem.trim(),
|
||||
privateKeyPem: input.privateKeyPem?.trim() ?? null,
|
||||
createdBy: actorUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
@@ -100,8 +100,8 @@ export async function updateCaCertificate(id: number, input: Partial<CaCertifica
|
||||
.update(caCertificates)
|
||||
.set({
|
||||
name: input.name?.trim() ?? existing.name,
|
||||
certificatePem: input.certificate_pem?.trim() ?? existing.certificate_pem,
|
||||
...(input.private_key_pem !== undefined ? { privateKeyPem: input.private_key_pem?.trim() ?? null } : {}),
|
||||
certificatePem: input.certificatePem?.trim() ?? existing.certificatePem,
|
||||
...(input.privateKeyPem !== undefined ? { privateKeyPem: input.privateKeyPem?.trim() ?? null } : {}),
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(caCertificates.id, id));
|
||||
|
||||
@@ -10,23 +10,23 @@ export type Certificate = {
|
||||
id: number;
|
||||
name: string;
|
||||
type: CertificateType;
|
||||
domain_names: string[];
|
||||
auto_renew: boolean;
|
||||
provider_options: Record<string, unknown> | null;
|
||||
certificate_pem: string | null;
|
||||
private_key_pem: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
domainNames: string[];
|
||||
autoRenew: boolean;
|
||||
providerOptions: Record<string, unknown> | null;
|
||||
certificatePem: string | null;
|
||||
privateKeyPem: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CertificateInput = {
|
||||
name: string;
|
||||
type: CertificateType;
|
||||
domain_names: string[];
|
||||
auto_renew?: boolean;
|
||||
provider_options?: Record<string, unknown> | null;
|
||||
certificate_pem?: string | null;
|
||||
private_key_pem?: string | null;
|
||||
domainNames: string[];
|
||||
autoRenew?: boolean;
|
||||
providerOptions?: Record<string, unknown> | null;
|
||||
certificatePem?: string | null;
|
||||
privateKeyPem?: string | null;
|
||||
};
|
||||
|
||||
type CertificateRow = typeof certificates.$inferSelect;
|
||||
@@ -36,13 +36,13 @@ function parseCertificate(row: CertificateRow): Certificate {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type as CertificateType,
|
||||
domain_names: JSON.parse(row.domainNames),
|
||||
auto_renew: row.autoRenew,
|
||||
provider_options: row.providerOptions ? JSON.parse(row.providerOptions) : null,
|
||||
certificate_pem: row.certificatePem,
|
||||
private_key_pem: row.privateKeyPem,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
domainNames: JSON.parse(row.domainNames),
|
||||
autoRenew: row.autoRenew,
|
||||
providerOptions: row.providerOptions ? JSON.parse(row.providerOptions) : null,
|
||||
certificatePem: row.certificatePem,
|
||||
privateKeyPem: row.privateKeyPem,
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,11 +59,11 @@ export async function getCertificate(id: number): Promise<Certificate | null> {
|
||||
}
|
||||
|
||||
function validateCertificateInput(input: CertificateInput) {
|
||||
if (!input.domain_names || input.domain_names.length === 0) {
|
||||
if (!input.domainNames || input.domainNames.length === 0) {
|
||||
throw new Error("At least one domain is required for a certificate");
|
||||
}
|
||||
if (input.type === "imported") {
|
||||
if (!input.certificate_pem || !input.private_key_pem) {
|
||||
if (!input.certificatePem || !input.privateKeyPem) {
|
||||
throw new Error("Imported certificates require certificate and key PEM data");
|
||||
}
|
||||
}
|
||||
@@ -78,12 +78,12 @@ export async function createCertificate(input: CertificateInput, actorUserId: nu
|
||||
name: input.name.trim(),
|
||||
type: input.type,
|
||||
domainNames: JSON.stringify(
|
||||
Array.from(new Set(input.domain_names.map((domain) => domain.trim().toLowerCase())))
|
||||
Array.from(new Set(input.domainNames.map((domain) => domain.trim().toLowerCase())))
|
||||
),
|
||||
autoRenew: input.auto_renew ?? true,
|
||||
providerOptions: input.provider_options ? JSON.stringify(input.provider_options) : null,
|
||||
certificatePem: input.certificate_pem ?? null,
|
||||
privateKeyPem: input.private_key_pem ?? null,
|
||||
autoRenew: input.autoRenew ?? true,
|
||||
providerOptions: input.providerOptions ? JSON.stringify(input.providerOptions) : null,
|
||||
certificatePem: input.certificatePem ?? null,
|
||||
privateKeyPem: input.privateKeyPem ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: actorUserId
|
||||
@@ -114,11 +114,11 @@ export async function updateCertificate(id: number, input: Partial<CertificateIn
|
||||
const merged: CertificateInput = {
|
||||
name: input.name ?? existing.name,
|
||||
type: input.type ?? existing.type,
|
||||
domain_names: input.domain_names ?? existing.domain_names,
|
||||
auto_renew: input.auto_renew ?? existing.auto_renew,
|
||||
provider_options: input.provider_options ?? existing.provider_options,
|
||||
certificate_pem: input.certificate_pem ?? existing.certificate_pem,
|
||||
private_key_pem: input.private_key_pem ?? existing.private_key_pem
|
||||
domainNames: input.domainNames ?? existing.domainNames,
|
||||
autoRenew: input.autoRenew ?? existing.autoRenew,
|
||||
providerOptions: input.providerOptions ?? existing.providerOptions,
|
||||
certificatePem: input.certificatePem ?? existing.certificatePem,
|
||||
privateKeyPem: input.privateKeyPem ?? existing.privateKeyPem
|
||||
};
|
||||
|
||||
validateCertificateInput(merged);
|
||||
@@ -129,11 +129,11 @@ export async function updateCertificate(id: number, input: Partial<CertificateIn
|
||||
.set({
|
||||
name: merged.name.trim(),
|
||||
type: merged.type,
|
||||
domainNames: JSON.stringify(Array.from(new Set(merged.domain_names))),
|
||||
autoRenew: merged.auto_renew,
|
||||
providerOptions: merged.provider_options ? JSON.stringify(merged.provider_options) : null,
|
||||
certificatePem: merged.certificate_pem ?? null,
|
||||
privateKeyPem: merged.private_key_pem ?? null,
|
||||
domainNames: JSON.stringify(Array.from(new Set(merged.domainNames))),
|
||||
autoRenew: merged.autoRenew,
|
||||
providerOptions: merged.providerOptions ? JSON.stringify(merged.providerOptions) : null,
|
||||
certificatePem: merged.certificatePem ?? null,
|
||||
privateKeyPem: merged.privateKeyPem ?? null,
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(certificates.id, id));
|
||||
|
||||
@@ -92,9 +92,9 @@ export async function consumeRedirectIntent(
|
||||
|
||||
export type ForwardAuthSession = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
userId: number;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export async function createForwardAuthSession(
|
||||
@@ -118,9 +118,9 @@ export async function createForwardAuthSession(
|
||||
rawToken,
|
||||
session: {
|
||||
id: row.id,
|
||||
user_id: row.userId,
|
||||
expires_at: toIso(row.expiresAt)!,
|
||||
created_at: toIso(row.createdAt)!
|
||||
userId: row.userId,
|
||||
expiresAt: toIso(row.expiresAt)!,
|
||||
createdAt: toIso(row.createdAt)!
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -145,9 +145,9 @@ export async function listForwardAuthSessions(): Promise<ForwardAuthSession[]> {
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
user_id: r.userId,
|
||||
expires_at: toIso(r.expiresAt)!,
|
||||
created_at: toIso(r.createdAt)!
|
||||
userId: r.userId,
|
||||
expiresAt: toIso(r.expiresAt)!,
|
||||
createdAt: toIso(r.createdAt)!
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -232,10 +232,10 @@ export async function redeemExchangeCode(
|
||||
|
||||
export type ForwardAuthAccessEntry = {
|
||||
id: number;
|
||||
proxy_host_id: number;
|
||||
user_id: number | null;
|
||||
group_id: number | null;
|
||||
created_at: string;
|
||||
proxyHostId: number;
|
||||
userId: number | null;
|
||||
groupId: number | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export async function checkHostAccess(
|
||||
@@ -313,10 +313,10 @@ export async function getForwardAuthAccessForHost(
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
proxy_host_id: r.proxyHostId,
|
||||
user_id: r.userId,
|
||||
group_id: r.groupId,
|
||||
created_at: toIso(r.createdAt)!
|
||||
proxyHostId: r.proxyHostId,
|
||||
userId: r.userId,
|
||||
groupId: r.groupId,
|
||||
createdAt: toIso(r.createdAt)!
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -8,15 +8,15 @@ export type Group = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
members: GroupMember[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type GroupMember = {
|
||||
user_id: number;
|
||||
userId: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
created_at: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type GroupInput = {
|
||||
@@ -32,8 +32,8 @@ function toGroup(row: GroupRow, members: GroupMember[]): Group {
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
members,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,10 +61,10 @@ export async function listGroups(): Promise<Group[]> {
|
||||
for (const m of allMembers) {
|
||||
const bucket = membersByGroup.get(m.groupId) ?? [];
|
||||
bucket.push({
|
||||
user_id: m.userId,
|
||||
userId: m.userId,
|
||||
email: m.email,
|
||||
name: m.name,
|
||||
created_at: toIso(m.createdAt)!
|
||||
createdAt: toIso(m.createdAt)!
|
||||
});
|
||||
membersByGroup.set(m.groupId, bucket);
|
||||
}
|
||||
@@ -97,10 +97,10 @@ export async function getGroup(id: number): Promise<Group | null> {
|
||||
return toGroup(
|
||||
group,
|
||||
members.map((m) => ({
|
||||
user_id: m.userId,
|
||||
userId: m.userId,
|
||||
email: m.email,
|
||||
name: m.name,
|
||||
created_at: toIso(m.createdAt)!
|
||||
createdAt: toIso(m.createdAt)!
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import { encryptSecret } from "../secret";
|
||||
export type Instance = {
|
||||
id: number;
|
||||
name: string;
|
||||
base_url: string;
|
||||
baseUrl: string;
|
||||
enabled: boolean;
|
||||
has_token: boolean;
|
||||
last_sync_at: string | null;
|
||||
last_sync_error: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
hasToken: boolean;
|
||||
lastSyncAt: string | null;
|
||||
lastSyncError: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type InstanceInput = {
|
||||
@@ -28,13 +28,13 @@ function toInstance(row: InstanceRow): Instance {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
base_url: row.baseUrl,
|
||||
baseUrl: row.baseUrl,
|
||||
enabled: Boolean(row.enabled),
|
||||
has_token: row.apiToken.length > 0,
|
||||
last_sync_at: row.lastSyncAt ? toIso(row.lastSyncAt) : null,
|
||||
last_sync_error: row.lastSyncError ?? null,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
hasToken: row.apiToken.length > 0,
|
||||
lastSyncAt: row.lastSyncAt ? toIso(row.lastSyncAt) : null,
|
||||
lastSyncError: row.lastSyncError ?? null,
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,26 +6,26 @@ import { desc, eq } from "drizzle-orm";
|
||||
|
||||
export type IssuedClientCertificate = {
|
||||
id: number;
|
||||
ca_certificate_id: number;
|
||||
common_name: string;
|
||||
serial_number: string;
|
||||
fingerprint_sha256: string;
|
||||
certificate_pem: string;
|
||||
valid_from: string;
|
||||
valid_to: string;
|
||||
revoked_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
caCertificateId: number;
|
||||
commonName: string;
|
||||
serialNumber: string;
|
||||
fingerprintSha256: string;
|
||||
certificatePem: string;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
revokedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type IssuedClientCertificateInput = {
|
||||
ca_certificate_id: number;
|
||||
common_name: string;
|
||||
serial_number: string;
|
||||
fingerprint_sha256: string;
|
||||
certificate_pem: string;
|
||||
valid_from: string;
|
||||
valid_to: string;
|
||||
caCertificateId: number;
|
||||
commonName: string;
|
||||
serialNumber: string;
|
||||
fingerprintSha256: string;
|
||||
certificatePem: string;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
};
|
||||
|
||||
type IssuedClientCertificateRow = typeof issuedClientCertificates.$inferSelect;
|
||||
@@ -33,16 +33,16 @@ type IssuedClientCertificateRow = typeof issuedClientCertificates.$inferSelect;
|
||||
function parseIssuedClientCertificate(row: IssuedClientCertificateRow): IssuedClientCertificate {
|
||||
return {
|
||||
id: row.id,
|
||||
ca_certificate_id: row.caCertificateId,
|
||||
common_name: row.commonName,
|
||||
serial_number: row.serialNumber,
|
||||
fingerprint_sha256: row.fingerprintSha256,
|
||||
certificate_pem: row.certificatePem,
|
||||
valid_from: toIso(row.validFrom)!,
|
||||
valid_to: toIso(row.validTo)!,
|
||||
revoked_at: toIso(row.revokedAt),
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
caCertificateId: row.caCertificateId,
|
||||
commonName: row.commonName,
|
||||
serialNumber: row.serialNumber,
|
||||
fingerprintSha256: row.fingerprintSha256,
|
||||
certificatePem: row.certificatePem,
|
||||
validFrom: toIso(row.validFrom)!,
|
||||
validTo: toIso(row.validTo)!,
|
||||
revokedAt: toIso(row.revokedAt),
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,13 +69,13 @@ export async function createIssuedClientCertificate(
|
||||
const [record] = await db
|
||||
.insert(issuedClientCertificates)
|
||||
.values({
|
||||
caCertificateId: input.ca_certificate_id,
|
||||
commonName: input.common_name.trim(),
|
||||
serialNumber: input.serial_number.trim(),
|
||||
fingerprintSha256: input.fingerprint_sha256.trim(),
|
||||
certificatePem: input.certificate_pem.trim(),
|
||||
validFrom: input.valid_from,
|
||||
validTo: input.valid_to,
|
||||
caCertificateId: input.caCertificateId,
|
||||
commonName: input.commonName.trim(),
|
||||
serialNumber: input.serialNumber.trim(),
|
||||
fingerprintSha256: input.fingerprintSha256.trim(),
|
||||
certificatePem: input.certificatePem.trim(),
|
||||
validFrom: input.validFrom,
|
||||
validTo: input.validTo,
|
||||
createdBy: actorUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
@@ -91,10 +91,10 @@ export async function createIssuedClientCertificate(
|
||||
action: "create",
|
||||
entityType: "issued_client_certificate",
|
||||
entityId: record.id,
|
||||
summary: `Issued client certificate ${input.common_name}`,
|
||||
summary: `Issued client certificate ${input.commonName}`,
|
||||
data: {
|
||||
caCertificateId: input.ca_certificate_id,
|
||||
serialNumber: input.serial_number
|
||||
caCertificateId: input.caCertificateId,
|
||||
serialNumber: input.serialNumber
|
||||
}
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
@@ -109,7 +109,7 @@ export async function revokeIssuedClientCertificate(
|
||||
if (!existing) {
|
||||
throw new Error("Issued client certificate not found");
|
||||
}
|
||||
if (existing.revoked_at) {
|
||||
if (existing.revokedAt) {
|
||||
throw new Error("Issued client certificate is already revoked");
|
||||
}
|
||||
|
||||
@@ -127,10 +127,10 @@ export async function revokeIssuedClientCertificate(
|
||||
action: "revoke",
|
||||
entityType: "issued_client_certificate",
|
||||
entityId: id,
|
||||
summary: `Revoked client certificate ${existing.common_name}`,
|
||||
summary: `Revoked client certificate ${existing.commonName}`,
|
||||
data: {
|
||||
caCertificateId: existing.ca_certificate_id,
|
||||
serialNumber: existing.serial_number
|
||||
caCertificateId: existing.caCertificateId,
|
||||
serialNumber: existing.serialNumber
|
||||
}
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
|
||||
@@ -113,41 +113,41 @@ export type L4ProxyHost = {
|
||||
id: number;
|
||||
name: string;
|
||||
protocol: L4Protocol;
|
||||
listen_address: string;
|
||||
listenAddress: string;
|
||||
upstreams: string[];
|
||||
matcher_type: L4MatcherType;
|
||||
matcher_value: string[];
|
||||
tls_termination: boolean;
|
||||
proxy_protocol_version: L4ProxyProtocolVersion | null;
|
||||
proxy_protocol_receive: boolean;
|
||||
matcherType: L4MatcherType;
|
||||
matcherValue: string[];
|
||||
tlsTermination: boolean;
|
||||
proxyProtocolVersion: L4ProxyProtocolVersion | null;
|
||||
proxyProtocolReceive: boolean;
|
||||
enabled: boolean;
|
||||
meta: L4ProxyHostMeta | null;
|
||||
load_balancer: L4LoadBalancerConfig | null;
|
||||
dns_resolver: L4DnsResolverConfig | null;
|
||||
upstream_dns_resolution: L4UpstreamDnsResolutionConfig | null;
|
||||
loadBalancer: L4LoadBalancerConfig | null;
|
||||
dnsResolver: L4DnsResolverConfig | null;
|
||||
upstreamDnsResolution: L4UpstreamDnsResolutionConfig | null;
|
||||
geoblock: L4GeoBlockConfig | null;
|
||||
geoblock_mode: L4GeoBlockMode;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
geoblockMode: L4GeoBlockMode;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type L4ProxyHostInput = {
|
||||
name: string;
|
||||
protocol: L4Protocol;
|
||||
listen_address: string;
|
||||
listenAddress: string;
|
||||
upstreams: string[];
|
||||
matcher_type?: L4MatcherType;
|
||||
matcher_value?: string[];
|
||||
tls_termination?: boolean;
|
||||
proxy_protocol_version?: L4ProxyProtocolVersion | null;
|
||||
proxy_protocol_receive?: boolean;
|
||||
matcherType?: L4MatcherType;
|
||||
matcherValue?: string[];
|
||||
tlsTermination?: boolean;
|
||||
proxyProtocolVersion?: L4ProxyProtocolVersion | null;
|
||||
proxyProtocolReceive?: boolean;
|
||||
enabled?: boolean;
|
||||
meta?: L4ProxyHostMeta | null;
|
||||
load_balancer?: Partial<L4LoadBalancerConfig> | null;
|
||||
dns_resolver?: Partial<L4DnsResolverConfig> | null;
|
||||
upstream_dns_resolution?: Partial<L4UpstreamDnsResolutionConfig> | null;
|
||||
loadBalancer?: Partial<L4LoadBalancerConfig> | null;
|
||||
dnsResolver?: Partial<L4DnsResolverConfig> | null;
|
||||
upstreamDnsResolution?: Partial<L4UpstreamDnsResolutionConfig> | null;
|
||||
geoblock?: L4GeoBlockConfig | null;
|
||||
geoblock_mode?: L4GeoBlockMode;
|
||||
geoblockMode?: L4GeoBlockMode;
|
||||
};
|
||||
|
||||
const VALID_PROTOCOLS: L4Protocol[] = ["tcp", "udp"];
|
||||
@@ -363,22 +363,22 @@ function parseL4ProxyHost(row: L4ProxyHostRow): L4ProxyHost {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
protocol: row.protocol as L4Protocol,
|
||||
listen_address: row.listenAddress,
|
||||
listenAddress: row.listenAddress,
|
||||
upstreams: safeJsonParse<string[]>(row.upstreams, []),
|
||||
matcher_type: (row.matcherType as L4MatcherType) || "none",
|
||||
matcher_value: safeJsonParse<string[]>(row.matcherValue, []),
|
||||
tls_termination: row.tlsTermination,
|
||||
proxy_protocol_version: row.proxyProtocolVersion as L4ProxyProtocolVersion | null,
|
||||
proxy_protocol_receive: row.proxyProtocolReceive,
|
||||
matcherType: (row.matcherType as L4MatcherType) || "none",
|
||||
matcherValue: safeJsonParse<string[]>(row.matcherValue, []),
|
||||
tlsTermination: row.tlsTermination,
|
||||
proxyProtocolVersion: row.proxyProtocolVersion as L4ProxyProtocolVersion | null,
|
||||
proxyProtocolReceive: row.proxyProtocolReceive,
|
||||
enabled: row.enabled,
|
||||
meta: Object.keys(meta).length > 0 ? meta : null,
|
||||
load_balancer: hydrateL4LoadBalancer(meta.load_balancer),
|
||||
dns_resolver: hydrateL4DnsResolver(meta.dns_resolver),
|
||||
upstream_dns_resolution: hydrateL4UpstreamDnsResolution(meta.upstream_dns_resolution),
|
||||
loadBalancer: hydrateL4LoadBalancer(meta.load_balancer),
|
||||
dnsResolver: hydrateL4DnsResolver(meta.dns_resolver),
|
||||
upstreamDnsResolution: hydrateL4UpstreamDnsResolution(meta.upstream_dns_resolution),
|
||||
geoblock: meta.geoblock?.enabled ? meta.geoblock : null,
|
||||
geoblock_mode: meta.geoblock_mode ?? "merge",
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!,
|
||||
geoblockMode: meta.geoblock_mode ?? "merge",
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ function validateL4Input(input: L4ProxyHostInput | Partial<L4ProxyHostInput>, is
|
||||
if (!input.protocol || !VALID_PROTOCOLS.includes(input.protocol)) {
|
||||
throw new Error("Protocol must be 'tcp' or 'udp'");
|
||||
}
|
||||
if (!input.listen_address?.trim()) {
|
||||
if (!input.listenAddress?.trim()) {
|
||||
throw new Error("Listen address is required");
|
||||
}
|
||||
if (!input.upstreams || input.upstreams.length === 0) {
|
||||
@@ -398,8 +398,8 @@ function validateL4Input(input: L4ProxyHostInput | Partial<L4ProxyHostInput>, is
|
||||
}
|
||||
}
|
||||
|
||||
if (input.listen_address !== undefined) {
|
||||
const addr = input.listen_address.trim();
|
||||
if (input.listenAddress !== undefined) {
|
||||
const addr = input.listenAddress.trim();
|
||||
// Must be :PORT or HOST:PORT
|
||||
const portMatch = addr.match(/:(\d+)$/);
|
||||
if (!portMatch) {
|
||||
@@ -415,22 +415,22 @@ function validateL4Input(input: L4ProxyHostInput | Partial<L4ProxyHostInput>, is
|
||||
throw new Error("Protocol must be 'tcp' or 'udp'");
|
||||
}
|
||||
|
||||
if (input.matcher_type !== undefined && !VALID_MATCHER_TYPES.includes(input.matcher_type)) {
|
||||
if (input.matcherType !== undefined && !VALID_MATCHER_TYPES.includes(input.matcherType)) {
|
||||
throw new Error(`Matcher type must be one of: ${VALID_MATCHER_TYPES.join(", ")}`);
|
||||
}
|
||||
|
||||
if (input.matcher_type === "tls_sni" || input.matcher_type === "http_host") {
|
||||
if (!input.matcher_value || input.matcher_value.length === 0) {
|
||||
if (input.matcherType === "tls_sni" || input.matcherType === "http_host") {
|
||||
if (!input.matcherValue || input.matcherValue.length === 0) {
|
||||
throw new Error("Matcher value is required for TLS SNI and HTTP Host matchers");
|
||||
}
|
||||
}
|
||||
|
||||
if (input.tls_termination && input.protocol === "udp") {
|
||||
if (input.tlsTermination && input.protocol === "udp") {
|
||||
throw new Error("TLS termination is only supported with TCP protocol");
|
||||
}
|
||||
|
||||
if (input.proxy_protocol_version !== undefined && input.proxy_protocol_version !== null) {
|
||||
if (!VALID_PROXY_PROTOCOL_VERSIONS.includes(input.proxy_protocol_version)) {
|
||||
if (input.proxyProtocolVersion !== undefined && input.proxyProtocolVersion !== null) {
|
||||
if (!VALID_PROXY_PROTOCOL_VERSIONS.includes(input.proxyProtocolVersion)) {
|
||||
throw new Error("Proxy protocol version must be 'v1' or 'v2'");
|
||||
}
|
||||
}
|
||||
@@ -465,10 +465,10 @@ export async function countL4ProxyHosts(search?: string): Promise<number> {
|
||||
const L4_SORT_COLUMNS: Record<string, any> = {
|
||||
name: l4ProxyHosts.name,
|
||||
protocol: l4ProxyHosts.protocol,
|
||||
listen_address: l4ProxyHosts.listenAddress,
|
||||
listenAddress: l4ProxyHosts.listenAddress,
|
||||
upstreams: l4ProxyHosts.upstreams,
|
||||
enabled: l4ProxyHosts.enabled,
|
||||
created_at: l4ProxyHosts.createdAt,
|
||||
createdAt: l4ProxyHosts.createdAt,
|
||||
};
|
||||
|
||||
export async function listL4ProxyHostsPaginated(
|
||||
@@ -506,21 +506,21 @@ export async function createL4ProxyHost(input: L4ProxyHostInput, actorUserId: nu
|
||||
.values({
|
||||
name: input.name.trim(),
|
||||
protocol: input.protocol,
|
||||
listenAddress: input.listen_address.trim(),
|
||||
listenAddress: input.listenAddress.trim(),
|
||||
upstreams: JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))),
|
||||
matcherType: input.matcher_type ?? "none",
|
||||
matcherValue: input.matcher_value ? JSON.stringify(input.matcher_value.map((v) => v.trim()).filter(Boolean)) : null,
|
||||
tlsTermination: input.tls_termination ?? false,
|
||||
proxyProtocolVersion: input.proxy_protocol_version ?? null,
|
||||
proxyProtocolReceive: input.proxy_protocol_receive ?? false,
|
||||
matcherType: input.matcherType ?? "none",
|
||||
matcherValue: input.matcherValue ? JSON.stringify(input.matcherValue.map((v) => v.trim()).filter(Boolean)) : null,
|
||||
tlsTermination: input.tlsTermination ?? false,
|
||||
proxyProtocolVersion: input.proxyProtocolVersion ?? null,
|
||||
proxyProtocolReceive: input.proxyProtocolReceive ?? false,
|
||||
ownerUserId: actorUserId,
|
||||
meta: (() => {
|
||||
const meta: L4ProxyHostMeta = { ...(input.meta ?? {}) };
|
||||
if (input.load_balancer) meta.load_balancer = dehydrateL4LoadBalancer(input.load_balancer);
|
||||
if (input.dns_resolver) meta.dns_resolver = dehydrateL4DnsResolver(input.dns_resolver);
|
||||
if (input.upstream_dns_resolution) meta.upstream_dns_resolution = dehydrateL4UpstreamDnsResolution(input.upstream_dns_resolution);
|
||||
if (input.loadBalancer) meta.load_balancer = dehydrateL4LoadBalancer(input.loadBalancer);
|
||||
if (input.dnsResolver) meta.dns_resolver = dehydrateL4DnsResolver(input.dnsResolver);
|
||||
if (input.upstreamDnsResolution) meta.upstream_dns_resolution = dehydrateL4UpstreamDnsResolution(input.upstreamDnsResolution);
|
||||
if (input.geoblock) meta.geoblock = input.geoblock;
|
||||
if (input.geoblock_mode && input.geoblock_mode !== "merge") meta.geoblock_mode = input.geoblock_mode;
|
||||
if (input.geoblockMode && input.geoblockMode !== "merge") meta.geoblock_mode = input.geoblockMode;
|
||||
return Object.keys(meta).length > 0 ? JSON.stringify(meta) : null;
|
||||
})(),
|
||||
enabled: input.enabled ?? true,
|
||||
@@ -562,14 +562,14 @@ export async function updateL4ProxyHost(id: number, input: Partial<L4ProxyHostIn
|
||||
// For validation, merge with existing to check cross-field constraints
|
||||
const merged = {
|
||||
protocol: input.protocol ?? existing.protocol,
|
||||
tls_termination: input.tls_termination ?? existing.tls_termination,
|
||||
matcher_type: input.matcher_type ?? existing.matcher_type,
|
||||
matcher_value: input.matcher_value ?? existing.matcher_value,
|
||||
tlsTermination: input.tlsTermination ?? existing.tlsTermination,
|
||||
matcherType: input.matcherType ?? existing.matcherType,
|
||||
matcherValue: input.matcherValue ?? existing.matcherValue,
|
||||
};
|
||||
if (merged.tls_termination && merged.protocol === "udp") {
|
||||
if (merged.tlsTermination && merged.protocol === "udp") {
|
||||
throw new Error("TLS termination is only supported with TCP protocol");
|
||||
}
|
||||
if ((merged.matcher_type === "tls_sni" || merged.matcher_type === "http_host") && merged.matcher_value.length === 0) {
|
||||
if ((merged.matcherType === "tls_sni" || merged.matcherType === "http_host") && merged.matcherValue.length === 0) {
|
||||
throw new Error("Matcher value is required for TLS SNI and HTTP Host matchers");
|
||||
}
|
||||
|
||||
@@ -581,57 +581,57 @@ export async function updateL4ProxyHost(id: number, input: Partial<L4ProxyHostIn
|
||||
.set({
|
||||
...(input.name !== undefined ? { name: input.name.trim() } : {}),
|
||||
...(input.protocol !== undefined ? { protocol: input.protocol } : {}),
|
||||
...(input.listen_address !== undefined ? { listenAddress: input.listen_address.trim() } : {}),
|
||||
...(input.listenAddress !== undefined ? { listenAddress: input.listenAddress.trim() } : {}),
|
||||
...(input.upstreams !== undefined
|
||||
? { upstreams: JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))) }
|
||||
: {}),
|
||||
...(input.matcher_type !== undefined ? { matcherType: input.matcher_type } : {}),
|
||||
...(input.matcher_value !== undefined
|
||||
? { matcherValue: JSON.stringify(input.matcher_value.map((v) => v.trim()).filter(Boolean)) }
|
||||
...(input.matcherType !== undefined ? { matcherType: input.matcherType } : {}),
|
||||
...(input.matcherValue !== undefined
|
||||
? { matcherValue: JSON.stringify(input.matcherValue.map((v) => v.trim()).filter(Boolean)) }
|
||||
: {}),
|
||||
...(input.tls_termination !== undefined ? { tlsTermination: input.tls_termination } : {}),
|
||||
...(input.proxy_protocol_version !== undefined ? { proxyProtocolVersion: input.proxy_protocol_version } : {}),
|
||||
...(input.proxy_protocol_receive !== undefined ? { proxyProtocolReceive: input.proxy_protocol_receive } : {}),
|
||||
...(input.tlsTermination !== undefined ? { tlsTermination: input.tlsTermination } : {}),
|
||||
...(input.proxyProtocolVersion !== undefined ? { proxyProtocolVersion: input.proxyProtocolVersion } : {}),
|
||||
...(input.proxyProtocolReceive !== undefined ? { proxyProtocolReceive: input.proxyProtocolReceive } : {}),
|
||||
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
||||
...(() => {
|
||||
const hasMetaChanges =
|
||||
input.meta !== undefined ||
|
||||
input.load_balancer !== undefined ||
|
||||
input.dns_resolver !== undefined ||
|
||||
input.upstream_dns_resolution !== undefined;
|
||||
input.loadBalancer !== undefined ||
|
||||
input.dnsResolver !== undefined ||
|
||||
input.upstreamDnsResolution !== undefined;
|
||||
if (!hasMetaChanges) return {};
|
||||
|
||||
// Start from existing meta
|
||||
const existingMeta: L4ProxyHostMeta = {
|
||||
...(existing.load_balancer ? { load_balancer: dehydrateL4LoadBalancer(existing.load_balancer) } : {}),
|
||||
...(existing.dns_resolver ? { dns_resolver: dehydrateL4DnsResolver(existing.dns_resolver) } : {}),
|
||||
...(existing.upstream_dns_resolution ? { upstream_dns_resolution: dehydrateL4UpstreamDnsResolution(existing.upstream_dns_resolution) } : {}),
|
||||
...(existing.loadBalancer ? { load_balancer: dehydrateL4LoadBalancer(existing.loadBalancer) } : {}),
|
||||
...(existing.dnsResolver ? { dns_resolver: dehydrateL4DnsResolver(existing.dnsResolver) } : {}),
|
||||
...(existing.upstreamDnsResolution ? { upstream_dns_resolution: dehydrateL4UpstreamDnsResolution(existing.upstreamDnsResolution) } : {}),
|
||||
...(existing.geoblock ? { geoblock: existing.geoblock } : {}),
|
||||
...(existing.geoblock_mode !== "merge" ? { geoblock_mode: existing.geoblock_mode } : {}),
|
||||
...(existing.geoblockMode !== "merge" ? { geoblock_mode: existing.geoblockMode } : {}),
|
||||
};
|
||||
|
||||
// Apply direct meta override if provided
|
||||
const meta: L4ProxyHostMeta = input.meta !== undefined ? { ...(input.meta ?? {}) } : { ...existingMeta };
|
||||
|
||||
// Apply structured field overrides
|
||||
if (input.load_balancer !== undefined) {
|
||||
const lb = dehydrateL4LoadBalancer(input.load_balancer);
|
||||
if (input.loadBalancer !== undefined) {
|
||||
const lb = dehydrateL4LoadBalancer(input.loadBalancer);
|
||||
if (lb) {
|
||||
meta.load_balancer = lb;
|
||||
} else {
|
||||
delete meta.load_balancer;
|
||||
}
|
||||
}
|
||||
if (input.dns_resolver !== undefined) {
|
||||
const dr = dehydrateL4DnsResolver(input.dns_resolver);
|
||||
if (input.dnsResolver !== undefined) {
|
||||
const dr = dehydrateL4DnsResolver(input.dnsResolver);
|
||||
if (dr) {
|
||||
meta.dns_resolver = dr;
|
||||
} else {
|
||||
delete meta.dns_resolver;
|
||||
}
|
||||
}
|
||||
if (input.upstream_dns_resolution !== undefined) {
|
||||
const udr = dehydrateL4UpstreamDnsResolution(input.upstream_dns_resolution);
|
||||
if (input.upstreamDnsResolution !== undefined) {
|
||||
const udr = dehydrateL4UpstreamDnsResolution(input.upstreamDnsResolution);
|
||||
if (udr) {
|
||||
meta.upstream_dns_resolution = udr;
|
||||
} else {
|
||||
@@ -645,9 +645,9 @@ export async function updateL4ProxyHost(id: number, input: Partial<L4ProxyHostIn
|
||||
delete meta.geoblock;
|
||||
}
|
||||
}
|
||||
if (input.geoblock_mode !== undefined) {
|
||||
if (input.geoblock_mode !== "merge") {
|
||||
meta.geoblock_mode = input.geoblock_mode;
|
||||
if (input.geoblockMode !== undefined) {
|
||||
if (input.geoblockMode !== "merge") {
|
||||
meta.geoblock_mode = input.geoblockMode;
|
||||
} else {
|
||||
delete meta.geoblock_mode;
|
||||
}
|
||||
|
||||
@@ -8,23 +8,23 @@ import { asc, desc, eq, inArray } from "drizzle-orm";
|
||||
|
||||
export type MtlsAccessRule = {
|
||||
id: number;
|
||||
proxy_host_id: number;
|
||||
path_pattern: string;
|
||||
allowed_role_ids: number[];
|
||||
allowed_cert_ids: number[];
|
||||
deny_all: boolean;
|
||||
proxyHostId: number;
|
||||
pathPattern: string;
|
||||
allowedRoleIds: number[];
|
||||
allowedCertIds: number[];
|
||||
denyAll: boolean;
|
||||
priority: number;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type MtlsAccessRuleInput = {
|
||||
proxy_host_id: number;
|
||||
path_pattern: string;
|
||||
allowed_role_ids?: number[];
|
||||
allowed_cert_ids?: number[];
|
||||
deny_all?: boolean;
|
||||
proxyHostId: number;
|
||||
pathPattern: string;
|
||||
allowedRoleIds?: number[];
|
||||
allowedCertIds?: number[];
|
||||
denyAll?: boolean;
|
||||
priority?: number;
|
||||
description?: string | null;
|
||||
};
|
||||
@@ -44,15 +44,15 @@ function parseJsonIds(raw: string): number[] {
|
||||
function toMtlsAccessRule(row: RuleRow): MtlsAccessRule {
|
||||
return {
|
||||
id: row.id,
|
||||
proxy_host_id: row.proxyHostId,
|
||||
path_pattern: row.pathPattern,
|
||||
allowed_role_ids: parseJsonIds(row.allowedRoleIds),
|
||||
allowed_cert_ids: parseJsonIds(row.allowedCertIds),
|
||||
deny_all: row.denyAll,
|
||||
proxyHostId: row.proxyHostId,
|
||||
pathPattern: row.pathPattern,
|
||||
allowedRoleIds: parseJsonIds(row.allowedRoleIds),
|
||||
allowedCertIds: parseJsonIds(row.allowedCertIds),
|
||||
denyAll: row.denyAll,
|
||||
priority: row.priority,
|
||||
description: row.description,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!,
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,11 +82,11 @@ export async function createMtlsAccessRule(
|
||||
const [record] = await db
|
||||
.insert(mtlsAccessRules)
|
||||
.values({
|
||||
proxyHostId: input.proxy_host_id,
|
||||
pathPattern: input.path_pattern.trim(),
|
||||
allowedRoleIds: JSON.stringify(input.allowed_role_ids ?? []),
|
||||
allowedCertIds: JSON.stringify(input.allowed_cert_ids ?? []),
|
||||
denyAll: input.deny_all ?? false,
|
||||
proxyHostId: input.proxyHostId,
|
||||
pathPattern: input.pathPattern.trim(),
|
||||
allowedRoleIds: JSON.stringify(input.allowedRoleIds ?? []),
|
||||
allowedCertIds: JSON.stringify(input.allowedCertIds ?? []),
|
||||
denyAll: input.denyAll ?? false,
|
||||
priority: input.priority ?? 0,
|
||||
description: input.description ?? null,
|
||||
createdBy: actorUserId,
|
||||
@@ -102,7 +102,7 @@ export async function createMtlsAccessRule(
|
||||
action: "create",
|
||||
entityType: "mtls_access_rule",
|
||||
entityId: record.id,
|
||||
summary: `Created mTLS access rule for path ${input.path_pattern} on proxy host ${input.proxy_host_id}`,
|
||||
summary: `Created mTLS access rule for path ${input.pathPattern} on proxy host ${input.proxyHostId}`,
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
@@ -111,7 +111,7 @@ export async function createMtlsAccessRule(
|
||||
|
||||
export async function updateMtlsAccessRule(
|
||||
id: number,
|
||||
input: Partial<Omit<MtlsAccessRuleInput, "proxy_host_id">>,
|
||||
input: Partial<Omit<MtlsAccessRuleInput, "proxyHostId">>,
|
||||
actorUserId: number
|
||||
): Promise<MtlsAccessRule> {
|
||||
const existing = await db.query.mtlsAccessRules.findFirst({
|
||||
@@ -122,10 +122,10 @@ export async function updateMtlsAccessRule(
|
||||
const now = nowIso();
|
||||
const updates: Partial<typeof mtlsAccessRules.$inferInsert> = { updatedAt: now };
|
||||
|
||||
if (input.path_pattern !== undefined) updates.pathPattern = input.path_pattern.trim();
|
||||
if (input.allowed_role_ids !== undefined) updates.allowedRoleIds = JSON.stringify(input.allowed_role_ids);
|
||||
if (input.allowed_cert_ids !== undefined) updates.allowedCertIds = JSON.stringify(input.allowed_cert_ids);
|
||||
if (input.deny_all !== undefined) updates.denyAll = input.deny_all;
|
||||
if (input.pathPattern !== undefined) updates.pathPattern = input.pathPattern.trim();
|
||||
if (input.allowedRoleIds !== undefined) updates.allowedRoleIds = JSON.stringify(input.allowedRoleIds);
|
||||
if (input.allowedCertIds !== undefined) updates.allowedCertIds = JSON.stringify(input.allowedCertIds);
|
||||
if (input.denyAll !== undefined) updates.denyAll = input.denyAll;
|
||||
if (input.priority !== undefined) updates.priority = input.priority;
|
||||
if (input.description !== undefined) updates.description = input.description ?? null;
|
||||
|
||||
@@ -136,7 +136,7 @@ export async function updateMtlsAccessRule(
|
||||
action: "update",
|
||||
entityType: "mtls_access_rule",
|
||||
entityId: id,
|
||||
summary: `Updated mTLS access rule for path ${input.path_pattern ?? existing.pathPattern}`,
|
||||
summary: `Updated mTLS access rule for path ${input.pathPattern ?? existing.pathPattern}`,
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
@@ -183,10 +183,10 @@ export async function getAccessRulesForHosts(
|
||||
const map = new Map<number, MtlsAccessRule[]>();
|
||||
for (const row of rows) {
|
||||
const parsed = toMtlsAccessRule(row);
|
||||
let bucket = map.get(parsed.proxy_host_id);
|
||||
let bucket = map.get(parsed.proxyHostId);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
map.set(parsed.proxy_host_id, bucket);
|
||||
map.set(parsed.proxyHostId, bucket);
|
||||
}
|
||||
bucket.push(parsed);
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ export type MtlsRole = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
certificate_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
certificateCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type MtlsRoleInput = {
|
||||
@@ -26,7 +26,7 @@ export type MtlsRoleInput = {
|
||||
};
|
||||
|
||||
export type MtlsRoleWithCertificates = MtlsRole & {
|
||||
certificate_ids: number[];
|
||||
certificateIds: number[];
|
||||
};
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
@@ -46,9 +46,9 @@ function toMtlsRole(row: RoleRow, certCount: number): MtlsRole {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
certificate_count: certCount,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!,
|
||||
certificateCount: certCount,
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export async function getMtlsRole(id: number): Promise<MtlsRoleWithCertificates
|
||||
|
||||
return {
|
||||
...toMtlsRole(row, assignments.length),
|
||||
certificate_ids: assignments.map((a) => a.certId),
|
||||
certificateIds: assignments.map((a) => a.certId),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
181
src/lib/models/oauth-providers.ts
Normal file
181
src/lib/models/oauth-providers.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import db, { nowIso } from "../db";
|
||||
import { oauthProviders } from "../db/schema";
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
import { encryptSecret, decryptSecret } from "../secret";
|
||||
|
||||
export type OAuthProvider = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
issuer: string | null;
|
||||
authorizationUrl: string | null;
|
||||
tokenUrl: string | null;
|
||||
userinfoUrl: string | null;
|
||||
scopes: string;
|
||||
autoLink: boolean;
|
||||
enabled: boolean;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type DbProvider = typeof oauthProviders.$inferSelect;
|
||||
|
||||
function parseDbProvider(row: DbProvider): OAuthProvider {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
clientId: decryptSecret(row.clientId),
|
||||
clientSecret: decryptSecret(row.clientSecret),
|
||||
issuer: row.issuer,
|
||||
authorizationUrl: row.authorizationUrl,
|
||||
tokenUrl: row.tokenUrl,
|
||||
userinfoUrl: row.userinfoUrl,
|
||||
scopes: row.scopes,
|
||||
autoLink: row.autoLink,
|
||||
enabled: row.enabled,
|
||||
source: row.source,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createOAuthProvider(data: {
|
||||
name: string;
|
||||
type?: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
issuer?: string | null;
|
||||
authorizationUrl?: string | null;
|
||||
tokenUrl?: string | null;
|
||||
userinfoUrl?: string | null;
|
||||
scopes?: string;
|
||||
autoLink?: boolean;
|
||||
enabled?: boolean;
|
||||
source?: string;
|
||||
}): Promise<OAuthProvider> {
|
||||
const now = nowIso();
|
||||
const id = randomUUID();
|
||||
|
||||
const [row] = await db
|
||||
.insert(oauthProviders)
|
||||
.values({
|
||||
id,
|
||||
name: data.name,
|
||||
type: data.type ?? "oidc",
|
||||
clientId: encryptSecret(data.clientId),
|
||||
clientSecret: encryptSecret(data.clientSecret),
|
||||
issuer: data.issuer ?? null,
|
||||
authorizationUrl: data.authorizationUrl ?? null,
|
||||
tokenUrl: data.tokenUrl ?? null,
|
||||
userinfoUrl: data.userinfoUrl ?? null,
|
||||
scopes: data.scopes ?? "openid email profile",
|
||||
autoLink: data.autoLink ?? false,
|
||||
enabled: data.enabled ?? true,
|
||||
source: data.source ?? "ui",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return parseDbProvider(row);
|
||||
}
|
||||
|
||||
export async function listOAuthProviders(): Promise<OAuthProvider[]> {
|
||||
const rows = await db.query.oauthProviders.findMany({
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
});
|
||||
return rows.map(parseDbProvider);
|
||||
}
|
||||
|
||||
export async function listEnabledOAuthProviders(): Promise<OAuthProvider[]> {
|
||||
const rows = await db.query.oauthProviders.findMany({
|
||||
where: (table, { eq }) => eq(table.enabled, true),
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
});
|
||||
return rows.map(parseDbProvider);
|
||||
}
|
||||
|
||||
export async function getOAuthProvider(id: string): Promise<OAuthProvider | null> {
|
||||
const row = await db.query.oauthProviders.findFirst({
|
||||
where: (table, { eq }) => eq(table.id, id),
|
||||
});
|
||||
return row ? parseDbProvider(row) : null;
|
||||
}
|
||||
|
||||
export async function getOAuthProviderByName(name: string): Promise<OAuthProvider | null> {
|
||||
const row = await db.query.oauthProviders.findFirst({
|
||||
where: (table, { eq }) => eq(table.name, name),
|
||||
});
|
||||
return row ? parseDbProvider(row) : null;
|
||||
}
|
||||
|
||||
export async function updateOAuthProvider(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
type: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
issuer: string | null;
|
||||
authorizationUrl: string | null;
|
||||
tokenUrl: string | null;
|
||||
userinfoUrl: string | null;
|
||||
scopes: string;
|
||||
autoLink: boolean;
|
||||
enabled: boolean;
|
||||
}>
|
||||
): Promise<OAuthProvider | null> {
|
||||
const now = nowIso();
|
||||
|
||||
const updates: Record<string, unknown> = { updatedAt: now };
|
||||
|
||||
if (data.name !== undefined) updates.name = data.name;
|
||||
if (data.type !== undefined) updates.type = data.type;
|
||||
if (data.clientId !== undefined) updates.clientId = encryptSecret(data.clientId);
|
||||
if (data.clientSecret !== undefined) updates.clientSecret = encryptSecret(data.clientSecret);
|
||||
if (data.issuer !== undefined) updates.issuer = data.issuer;
|
||||
if (data.authorizationUrl !== undefined) updates.authorizationUrl = data.authorizationUrl;
|
||||
if (data.tokenUrl !== undefined) updates.tokenUrl = data.tokenUrl;
|
||||
if (data.userinfoUrl !== undefined) updates.userinfoUrl = data.userinfoUrl;
|
||||
if (data.scopes !== undefined) updates.scopes = data.scopes;
|
||||
if (data.autoLink !== undefined) updates.autoLink = data.autoLink;
|
||||
if (data.enabled !== undefined) updates.enabled = data.enabled;
|
||||
|
||||
const [row] = await db
|
||||
.update(oauthProviders)
|
||||
.set(updates)
|
||||
.where(eq(oauthProviders.id, id))
|
||||
.returning();
|
||||
|
||||
return row ? parseDbProvider(row) : null;
|
||||
}
|
||||
|
||||
export async function deleteOAuthProvider(id: string): Promise<void> {
|
||||
const row = await db.query.oauthProviders.findFirst({
|
||||
where: (table, { eq }) => eq(table.id, id),
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
throw new Error("OAuth provider not found");
|
||||
}
|
||||
|
||||
if (row.source === "env") {
|
||||
throw new Error("Cannot delete an environment-sourced OAuth provider");
|
||||
}
|
||||
|
||||
await db.delete(oauthProviders).where(eq(oauthProviders.id, id));
|
||||
}
|
||||
|
||||
export async function getProviderDisplayList(): Promise<Array<{ id: string; name: string }>> {
|
||||
const rows = await db.query.oauthProviders.findMany({
|
||||
where: (table, { eq }) => eq(table.enabled, true),
|
||||
orderBy: (table, { asc }) => asc(table.name),
|
||||
columns: { id: true, name: true },
|
||||
});
|
||||
return rows.map((r) => ({ id: r.id, name: r.name }));
|
||||
}
|
||||
@@ -279,60 +279,60 @@ export type ProxyHost = {
|
||||
name: string;
|
||||
domains: string[];
|
||||
upstreams: string[];
|
||||
certificate_id: number | null;
|
||||
access_list_id: number | null;
|
||||
ssl_forced: boolean;
|
||||
hsts_enabled: boolean;
|
||||
hsts_subdomains: boolean;
|
||||
allow_websocket: boolean;
|
||||
preserve_host_header: boolean;
|
||||
skip_https_hostname_validation: boolean;
|
||||
certificateId: number | null;
|
||||
accessListId: number | null;
|
||||
sslForced: boolean;
|
||||
hstsEnabled: boolean;
|
||||
hstsSubdomains: boolean;
|
||||
allowWebsocket: boolean;
|
||||
preserveHostHeader: boolean;
|
||||
skipHttpsHostnameValidation: boolean;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
custom_reverse_proxy_json: string | null;
|
||||
custom_pre_handlers_json: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
customReverseProxyJson: string | null;
|
||||
customPreHandlersJson: string | null;
|
||||
authentik: ProxyHostAuthentikConfig | null;
|
||||
load_balancer: LoadBalancerConfig | null;
|
||||
dns_resolver: DnsResolverConfig | null;
|
||||
upstream_dns_resolution: UpstreamDnsResolutionConfig | null;
|
||||
loadBalancer: LoadBalancerConfig | null;
|
||||
dnsResolver: DnsResolverConfig | null;
|
||||
upstreamDnsResolution: UpstreamDnsResolutionConfig | null;
|
||||
geoblock: GeoBlockSettings | null;
|
||||
geoblock_mode: GeoBlockMode;
|
||||
geoblockMode: GeoBlockMode;
|
||||
waf: WafHostConfig | null;
|
||||
mtls: MtlsConfig | null;
|
||||
cpm_forward_auth: CpmForwardAuthConfig | null;
|
||||
cpmForwardAuth: CpmForwardAuthConfig | null;
|
||||
redirects: RedirectRule[];
|
||||
rewrite: RewriteConfig | null;
|
||||
location_rules: LocationRule[];
|
||||
locationRules: LocationRule[];
|
||||
};
|
||||
|
||||
export type ProxyHostInput = {
|
||||
name: string;
|
||||
domains: string[];
|
||||
upstreams: string[];
|
||||
certificate_id?: number | null;
|
||||
access_list_id?: number | null;
|
||||
ssl_forced?: boolean;
|
||||
hsts_enabled?: boolean;
|
||||
hsts_subdomains?: boolean;
|
||||
allow_websocket?: boolean;
|
||||
preserve_host_header?: boolean;
|
||||
skip_https_hostname_validation?: boolean;
|
||||
certificateId?: number | null;
|
||||
accessListId?: number | null;
|
||||
sslForced?: boolean;
|
||||
hstsEnabled?: boolean;
|
||||
hstsSubdomains?: boolean;
|
||||
allowWebsocket?: boolean;
|
||||
preserveHostHeader?: boolean;
|
||||
skipHttpsHostnameValidation?: boolean;
|
||||
enabled?: boolean;
|
||||
custom_reverse_proxy_json?: string | null;
|
||||
custom_pre_handlers_json?: string | null;
|
||||
customReverseProxyJson?: string | null;
|
||||
customPreHandlersJson?: string | null;
|
||||
authentik?: ProxyHostAuthentikInput | null;
|
||||
load_balancer?: LoadBalancerInput | null;
|
||||
dns_resolver?: DnsResolverInput | null;
|
||||
upstream_dns_resolution?: UpstreamDnsResolutionInput | null;
|
||||
loadBalancer?: LoadBalancerInput | null;
|
||||
dnsResolver?: DnsResolverInput | null;
|
||||
upstreamDnsResolution?: UpstreamDnsResolutionInput | null;
|
||||
geoblock?: GeoBlockSettings | null;
|
||||
geoblock_mode?: GeoBlockMode;
|
||||
geoblockMode?: GeoBlockMode;
|
||||
waf?: WafHostConfig | null;
|
||||
mtls?: MtlsConfig | null;
|
||||
cpm_forward_auth?: CpmForwardAuthInput | null;
|
||||
cpmForwardAuth?: CpmForwardAuthInput | null;
|
||||
redirects?: RedirectRule[] | null;
|
||||
rewrite?: RewriteConfig | null;
|
||||
location_rules?: LocationRule[] | null;
|
||||
locationRules?: LocationRule[] | null;
|
||||
};
|
||||
|
||||
type ProxyHostRow = typeof proxyHosts.$inferSelect;
|
||||
@@ -1105,8 +1105,8 @@ function normalizeUpstreamDnsResolutionInput(
|
||||
function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): string | null {
|
||||
const next: ProxyHostMeta = { ...existing };
|
||||
|
||||
if (input.custom_reverse_proxy_json !== undefined) {
|
||||
const reverse = normalizeMetaValue(input.custom_reverse_proxy_json ?? null);
|
||||
if (input.customReverseProxyJson !== undefined) {
|
||||
const reverse = normalizeMetaValue(input.customReverseProxyJson ?? null);
|
||||
if (reverse) {
|
||||
next.custom_reverse_proxy_json = reverse;
|
||||
} else {
|
||||
@@ -1114,8 +1114,8 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
|
||||
}
|
||||
}
|
||||
|
||||
if (input.custom_pre_handlers_json !== undefined) {
|
||||
const pre = normalizeMetaValue(input.custom_pre_handlers_json ?? null);
|
||||
if (input.customPreHandlersJson !== undefined) {
|
||||
const pre = normalizeMetaValue(input.customPreHandlersJson ?? null);
|
||||
if (pre) {
|
||||
next.custom_pre_handlers_json = pre;
|
||||
} else {
|
||||
@@ -1132,8 +1132,8 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
|
||||
}
|
||||
}
|
||||
|
||||
if (input.load_balancer !== undefined) {
|
||||
const loadBalancer = normalizeLoadBalancerInput(input.load_balancer, existing.load_balancer);
|
||||
if (input.loadBalancer !== undefined) {
|
||||
const loadBalancer = normalizeLoadBalancerInput(input.loadBalancer, existing.load_balancer);
|
||||
if (loadBalancer) {
|
||||
next.load_balancer = loadBalancer;
|
||||
} else {
|
||||
@@ -1141,8 +1141,8 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
|
||||
}
|
||||
}
|
||||
|
||||
if (input.dns_resolver !== undefined) {
|
||||
const dnsResolver = normalizeDnsResolverInput(input.dns_resolver, existing.dns_resolver);
|
||||
if (input.dnsResolver !== undefined) {
|
||||
const dnsResolver = normalizeDnsResolverInput(input.dnsResolver, existing.dns_resolver);
|
||||
if (dnsResolver) {
|
||||
next.dns_resolver = dnsResolver;
|
||||
} else {
|
||||
@@ -1150,9 +1150,9 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
|
||||
}
|
||||
}
|
||||
|
||||
if (input.upstream_dns_resolution !== undefined) {
|
||||
if (input.upstreamDnsResolution !== undefined) {
|
||||
const upstreamDnsResolution = normalizeUpstreamDnsResolutionInput(
|
||||
input.upstream_dns_resolution,
|
||||
input.upstreamDnsResolution,
|
||||
existing.upstream_dns_resolution
|
||||
);
|
||||
if (upstreamDnsResolution) {
|
||||
@@ -1172,8 +1172,8 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
|
||||
}
|
||||
}
|
||||
|
||||
if (input.geoblock_mode !== undefined) {
|
||||
next.geoblock_mode = input.geoblock_mode;
|
||||
if (input.geoblockMode !== undefined) {
|
||||
next.geoblock_mode = input.geoblockMode;
|
||||
}
|
||||
|
||||
if (input.waf !== undefined) {
|
||||
@@ -1192,11 +1192,11 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
|
||||
}
|
||||
}
|
||||
|
||||
if (input.cpm_forward_auth !== undefined) {
|
||||
if (input.cpm_forward_auth && input.cpm_forward_auth.enabled) {
|
||||
if (input.cpmForwardAuth !== undefined) {
|
||||
if (input.cpmForwardAuth && input.cpmForwardAuth.enabled) {
|
||||
const cfa: CpmForwardAuthMeta = { enabled: true };
|
||||
if (input.cpm_forward_auth.protected_paths && input.cpm_forward_auth.protected_paths.length > 0) {
|
||||
cfa.protected_paths = input.cpm_forward_auth.protected_paths;
|
||||
if (input.cpmForwardAuth.protected_paths && input.cpmForwardAuth.protected_paths.length > 0) {
|
||||
cfa.protected_paths = input.cpmForwardAuth.protected_paths;
|
||||
}
|
||||
next.cpm_forward_auth = cfa;
|
||||
} else {
|
||||
@@ -1222,8 +1222,8 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
|
||||
}
|
||||
}
|
||||
|
||||
if (input.location_rules !== undefined) {
|
||||
const rules = sanitizeLocationRules(input.location_rules ?? []);
|
||||
if (input.locationRules !== undefined) {
|
||||
const rules = sanitizeLocationRules(input.locationRules ?? []);
|
||||
if (rules.length > 0) {
|
||||
next.location_rules = rules;
|
||||
} else {
|
||||
@@ -1537,33 +1537,33 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost {
|
||||
name: row.name,
|
||||
domains: JSON.parse(row.domains),
|
||||
upstreams: JSON.parse(row.upstreams),
|
||||
certificate_id: row.certificateId ?? null,
|
||||
access_list_id: row.accessListId ?? null,
|
||||
ssl_forced: row.sslForced,
|
||||
hsts_enabled: row.hstsEnabled,
|
||||
hsts_subdomains: row.hstsSubdomains,
|
||||
allow_websocket: row.allowWebsocket,
|
||||
preserve_host_header: row.preserveHostHeader,
|
||||
skip_https_hostname_validation: row.skipHttpsHostnameValidation,
|
||||
certificateId: row.certificateId ?? null,
|
||||
accessListId: row.accessListId ?? null,
|
||||
sslForced: row.sslForced,
|
||||
hstsEnabled: row.hstsEnabled,
|
||||
hstsSubdomains: row.hstsSubdomains,
|
||||
allowWebsocket: row.allowWebsocket,
|
||||
preserveHostHeader: row.preserveHostHeader,
|
||||
skipHttpsHostnameValidation: row.skipHttpsHostnameValidation,
|
||||
enabled: row.enabled,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!,
|
||||
custom_reverse_proxy_json: meta.custom_reverse_proxy_json ?? null,
|
||||
custom_pre_handlers_json: meta.custom_pre_handlers_json ?? null,
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!,
|
||||
customReverseProxyJson: meta.custom_reverse_proxy_json ?? null,
|
||||
customPreHandlersJson: meta.custom_pre_handlers_json ?? null,
|
||||
authentik: hydrateAuthentik(meta.authentik),
|
||||
load_balancer: hydrateLoadBalancer(meta.load_balancer),
|
||||
dns_resolver: hydrateDnsResolver(meta.dns_resolver),
|
||||
upstream_dns_resolution: hydrateUpstreamDnsResolution(meta.upstream_dns_resolution),
|
||||
loadBalancer: hydrateLoadBalancer(meta.load_balancer),
|
||||
dnsResolver: hydrateDnsResolver(meta.dns_resolver),
|
||||
upstreamDnsResolution: hydrateUpstreamDnsResolution(meta.upstream_dns_resolution),
|
||||
geoblock: hydrateGeoBlock(meta.geoblock),
|
||||
geoblock_mode: meta.geoblock_mode ?? "merge",
|
||||
geoblockMode: meta.geoblock_mode ?? "merge",
|
||||
waf: meta.waf ?? null,
|
||||
mtls: meta.mtls ?? null,
|
||||
cpm_forward_auth: meta.cpm_forward_auth?.enabled
|
||||
cpmForwardAuth: meta.cpm_forward_auth?.enabled
|
||||
? { enabled: true, protected_paths: meta.cpm_forward_auth.protected_paths ?? null }
|
||||
: null,
|
||||
redirects: meta.redirects ?? [],
|
||||
rewrite: meta.rewrite ?? null,
|
||||
location_rules: meta.location_rules ?? [],
|
||||
locationRules: meta.location_rules ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1590,7 +1590,7 @@ const PROXY_HOST_SORT_COLUMNS: Record<string, any> = {
|
||||
domains: proxyHosts.domains,
|
||||
upstreams: proxyHosts.upstreams,
|
||||
enabled: proxyHosts.enabled,
|
||||
created_at: proxyHosts.createdAt,
|
||||
createdAt: proxyHosts.createdAt,
|
||||
};
|
||||
|
||||
export async function listProxyHostsPaginated(
|
||||
@@ -1635,16 +1635,16 @@ export async function createProxyHost(input: ProxyHostInput, actorUserId: number
|
||||
name: input.name.trim(),
|
||||
domains: JSON.stringify(domains),
|
||||
upstreams: JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))),
|
||||
certificateId: input.certificate_id ?? null,
|
||||
accessListId: input.access_list_id ?? null,
|
||||
certificateId: input.certificateId ?? null,
|
||||
accessListId: input.accessListId ?? null,
|
||||
ownerUserId: actorUserId,
|
||||
sslForced: input.ssl_forced ?? true,
|
||||
hstsEnabled: input.hsts_enabled ?? true,
|
||||
hstsSubdomains: input.hsts_subdomains ?? false,
|
||||
allowWebsocket: input.allow_websocket ?? true,
|
||||
preserveHostHeader: input.preserve_host_header ?? true,
|
||||
sslForced: input.sslForced ?? true,
|
||||
hstsEnabled: input.hstsEnabled ?? true,
|
||||
hstsSubdomains: input.hstsSubdomains ?? false,
|
||||
allowWebsocket: input.allowWebsocket ?? true,
|
||||
preserveHostHeader: input.preserveHostHeader ?? true,
|
||||
meta,
|
||||
skipHttpsHostnameValidation: input.skip_https_hostname_validation ?? false,
|
||||
skipHttpsHostnameValidation: input.skipHttpsHostnameValidation ?? false,
|
||||
enabled: input.enabled ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
@@ -1689,20 +1689,20 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
|
||||
}
|
||||
const upstreams = input.upstreams ? JSON.stringify(Array.from(new Set(input.upstreams))) : JSON.stringify(existing.upstreams);
|
||||
const existingMeta: ProxyHostMeta = {
|
||||
custom_reverse_proxy_json: existing.custom_reverse_proxy_json ?? undefined,
|
||||
custom_pre_handlers_json: existing.custom_pre_handlers_json ?? undefined,
|
||||
custom_reverse_proxy_json: existing.customReverseProxyJson ?? undefined,
|
||||
custom_pre_handlers_json: existing.customPreHandlersJson ?? undefined,
|
||||
authentik: dehydrateAuthentik(existing.authentik),
|
||||
load_balancer: dehydrateLoadBalancer(existing.load_balancer),
|
||||
dns_resolver: dehydrateDnsResolver(existing.dns_resolver),
|
||||
upstream_dns_resolution: dehydrateUpstreamDnsResolution(existing.upstream_dns_resolution),
|
||||
load_balancer: dehydrateLoadBalancer(existing.loadBalancer),
|
||||
dns_resolver: dehydrateDnsResolver(existing.dnsResolver),
|
||||
upstream_dns_resolution: dehydrateUpstreamDnsResolution(existing.upstreamDnsResolution),
|
||||
geoblock: dehydrateGeoBlock(existing.geoblock),
|
||||
...(existing.geoblock_mode !== "merge" ? { geoblock_mode: existing.geoblock_mode } : {}),
|
||||
...(existing.geoblockMode !== "merge" ? { geoblock_mode: existing.geoblockMode } : {}),
|
||||
...(existing.waf ? { waf: existing.waf } : {}),
|
||||
...(existing.mtls ? { mtls: existing.mtls } : {}),
|
||||
...(existing.cpm_forward_auth?.enabled ? {
|
||||
...(existing.cpmForwardAuth?.enabled ? {
|
||||
cpm_forward_auth: {
|
||||
enabled: true,
|
||||
...(existing.cpm_forward_auth.protected_paths ? { protected_paths: existing.cpm_forward_auth.protected_paths } : {})
|
||||
...(existing.cpmForwardAuth.protected_paths ? { protected_paths: existing.cpmForwardAuth.protected_paths } : {})
|
||||
}
|
||||
} : {}),
|
||||
};
|
||||
@@ -1715,15 +1715,15 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
|
||||
name: input.name ?? existing.name,
|
||||
domains,
|
||||
upstreams,
|
||||
certificateId: input.certificate_id !== undefined ? input.certificate_id : existing.certificate_id,
|
||||
accessListId: input.access_list_id !== undefined ? input.access_list_id : existing.access_list_id,
|
||||
sslForced: input.ssl_forced ?? existing.ssl_forced,
|
||||
hstsEnabled: input.hsts_enabled ?? existing.hsts_enabled,
|
||||
hstsSubdomains: input.hsts_subdomains ?? existing.hsts_subdomains,
|
||||
allowWebsocket: input.allow_websocket ?? existing.allow_websocket,
|
||||
preserveHostHeader: input.preserve_host_header ?? existing.preserve_host_header,
|
||||
certificateId: input.certificateId !== undefined ? input.certificateId : existing.certificateId,
|
||||
accessListId: input.accessListId !== undefined ? input.accessListId : existing.accessListId,
|
||||
sslForced: input.sslForced ?? existing.sslForced,
|
||||
hstsEnabled: input.hstsEnabled ?? existing.hstsEnabled,
|
||||
hstsSubdomains: input.hstsSubdomains ?? existing.hstsSubdomains,
|
||||
allowWebsocket: input.allowWebsocket ?? existing.allowWebsocket,
|
||||
preserveHostHeader: input.preserveHostHeader ?? existing.preserveHostHeader,
|
||||
meta,
|
||||
skipHttpsHostnameValidation: input.skip_https_hostname_validation ?? existing.skip_https_hostname_validation,
|
||||
skipHttpsHostnameValidation: input.skipHttpsHostnameValidation ?? existing.skipHttpsHostnameValidation,
|
||||
enabled: input.enabled ?? existing.enabled,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import db, { nowIso, toIso } from "../db";
|
||||
import { users } from "../db/schema";
|
||||
import { users, accounts } from "../db/schema";
|
||||
import { and, count, eq } from "drizzle-orm";
|
||||
import { deleteUserForwardAuthSessions } from "./forward-auth";
|
||||
|
||||
@@ -7,14 +7,14 @@ export type User = {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
password_hash: string | null;
|
||||
passwordHash: string | null;
|
||||
role: "admin" | "user" | "viewer";
|
||||
provider: string;
|
||||
subject: string;
|
||||
avatar_url: string | null;
|
||||
provider: string | null;
|
||||
subject: string | null;
|
||||
avatarUrl: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type DbUser = typeof users.$inferSelect;
|
||||
@@ -24,14 +24,14 @@ function parseDbUser(user: DbUser): User {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
password_hash: user.passwordHash,
|
||||
passwordHash: user.passwordHash,
|
||||
role: user.role as "admin" | "user" | "viewer",
|
||||
provider: user.provider,
|
||||
subject: user.subject,
|
||||
avatar_url: user.avatarUrl,
|
||||
avatarUrl: user.avatarUrl,
|
||||
status: user.status,
|
||||
created_at: toIso(user.createdAt)!,
|
||||
updated_at: toIso(user.updatedAt)!
|
||||
createdAt: toIso(user.createdAt)!,
|
||||
updatedAt: toIso(user.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,8 +48,14 @@ export async function getUserCount(): Promise<number> {
|
||||
}
|
||||
|
||||
export async function findUserByProviderSubject(provider: string, subject: string): Promise<User | null> {
|
||||
const account = await db.select().from(accounts).where(
|
||||
and(eq(accounts.providerId, provider), eq(accounts.accountId, subject))
|
||||
).limit(1);
|
||||
|
||||
if (account.length === 0) return null;
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: (table, operators) => and(operators.eq(table.provider, provider), operators.eq(table.subject, subject))
|
||||
where: (table, { eq }) => eq(table.id, account[0].userId)
|
||||
});
|
||||
return user ? parseDbUser(user) : null;
|
||||
}
|
||||
@@ -68,7 +74,7 @@ export async function createUser(data: {
|
||||
role?: User["role"];
|
||||
provider: string;
|
||||
subject: string;
|
||||
avatar_url?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
passwordHash?: string | null;
|
||||
}): Promise<User> {
|
||||
const now = nowIso();
|
||||
@@ -84,7 +90,7 @@ export async function createUser(data: {
|
||||
role,
|
||||
provider: data.provider,
|
||||
subject: data.subject,
|
||||
avatarUrl: data.avatar_url ?? null,
|
||||
avatarUrl: data.avatarUrl ?? null,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
@@ -94,7 +100,7 @@ export async function createUser(data: {
|
||||
return parseDbUser(user);
|
||||
}
|
||||
|
||||
export async function updateUserProfile(userId: number, data: { email?: string; name?: string | null; avatar_url?: string | null }): Promise<User | null> {
|
||||
export async function updateUserProfile(userId: number, data: { email?: string; name?: string | null; avatarUrl?: string | null }): Promise<User | null> {
|
||||
const current = await getUserById(userId);
|
||||
if (!current) {
|
||||
return null;
|
||||
@@ -106,7 +112,7 @@ export async function updateUserProfile(userId: number, data: { email?: string;
|
||||
.set({
|
||||
email: data.email ?? current.email,
|
||||
name: data.name ?? current.name,
|
||||
avatarUrl: data.avatar_url ?? current.avatar_url,
|
||||
avatarUrl: data.avatarUrl ?? current.avatarUrl,
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(users.id, userId))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { randomBytes } from "crypto";
|
||||
import { randomBytes, randomUUID } from "crypto";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { config } from "../config";
|
||||
import { findUserByEmail, findUserByProviderSubject, getUserById } from "../models/user";
|
||||
import { findUserByEmail, getUserById } from "../models/user";
|
||||
import db from "../db";
|
||||
import { users, linkingTokens } from "../db/schema";
|
||||
import { eq, lt } from "drizzle-orm";
|
||||
import { users, linkingTokens, accounts } from "../db/schema";
|
||||
import { and, eq, lt } from "drizzle-orm";
|
||||
import { nowIso } from "../db";
|
||||
|
||||
const LINKING_TOKEN_EXPIRY = 5 * 60; // 5 minutes in seconds
|
||||
@@ -32,14 +32,13 @@ export async function decideLinkingStrategy(
|
||||
providerAccountId: string,
|
||||
email: string
|
||||
): Promise<LinkingDecision> {
|
||||
// Check if OAuth account already exists
|
||||
const existingOAuthUser = await findUserByProviderSubject(provider, providerAccountId);
|
||||
if (existingOAuthUser) {
|
||||
return {
|
||||
action: "signin_existing",
|
||||
userId: existingOAuthUser.id,
|
||||
reason: "OAuth account already linked"
|
||||
};
|
||||
// Check accounts table for existing OAuth connection
|
||||
const existingAccount = await db.select().from(accounts).where(
|
||||
and(eq(accounts.providerId, provider), eq(accounts.accountId, providerAccountId))
|
||||
).limit(1);
|
||||
|
||||
if (existingAccount.length > 0) {
|
||||
return { action: "signin_existing", userId: existingAccount[0].userId, reason: "OAuth account already linked" };
|
||||
}
|
||||
|
||||
// Check if email matches existing user
|
||||
@@ -52,7 +51,7 @@ export async function decideLinkingStrategy(
|
||||
}
|
||||
|
||||
// User exists with this email
|
||||
if (existingEmailUser.password_hash) {
|
||||
if (existingEmailUser.passwordHash) {
|
||||
// Has password - require manual linking with password verification
|
||||
return {
|
||||
action: "require_manual_link",
|
||||
@@ -188,25 +187,24 @@ export async function verifyAndLinkOAuth(
|
||||
providerAccountId: string
|
||||
): Promise<boolean> {
|
||||
const user = await getUserById(userId);
|
||||
if (!user || !user.password_hash) {
|
||||
if (!user || !user.passwordHash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = bcrypt.compareSync(password, user.password_hash);
|
||||
const isValid = bcrypt.compareSync(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update user to link OAuth
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
provider,
|
||||
subject: providerAccountId,
|
||||
updatedAt: nowIso()
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
// Insert OAuth account link
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
accountId: providerAccountId,
|
||||
providerId: provider,
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso()
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -227,20 +225,26 @@ export async function autoLinkOAuth(
|
||||
|
||||
// Don't auto-link if user has a password (unless explicitly called for authenticated linking)
|
||||
// This check is bypassed when called from the authenticated linking flow
|
||||
if (user.password_hash && !config.oauth.allowAutoLinking) {
|
||||
if (user.passwordHash && !config.oauth.allowAutoLinking) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update user to link OAuth
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
provider,
|
||||
subject: providerAccountId,
|
||||
avatarUrl: avatarUrl ?? user.avatar_url,
|
||||
updatedAt: nowIso()
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
// Insert OAuth account link
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
accountId: providerAccountId,
|
||||
providerId: provider,
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso()
|
||||
});
|
||||
|
||||
// Update avatar if provided
|
||||
if (avatarUrl) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ avatarUrl, updatedAt: nowIso() })
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -260,16 +264,22 @@ export async function linkOAuthAuthenticated(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update user to link OAuth
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
provider,
|
||||
subject: providerAccountId,
|
||||
avatarUrl: avatarUrl ?? user.avatar_url,
|
||||
updatedAt: nowIso()
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
// Insert OAuth account link
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
accountId: providerAccountId,
|
||||
providerId: provider,
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso()
|
||||
});
|
||||
|
||||
// Update avatar if provided
|
||||
if (avatarUrl) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ avatarUrl, updatedAt: nowIso() })
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
44
src/lib/services/oauth-provider-sync.ts
Normal file
44
src/lib/services/oauth-provider-sync.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { config } from "../config";
|
||||
import {
|
||||
getOAuthProviderByName,
|
||||
createOAuthProvider,
|
||||
updateOAuthProvider,
|
||||
} from "../models/oauth-providers";
|
||||
|
||||
/**
|
||||
* Sync OAUTH_* environment variables into the oauthProviders table.
|
||||
* Env-sourced providers are created with source="env" and are read-only in the UI.
|
||||
* Call this once at server startup.
|
||||
*/
|
||||
export async function syncEnvOAuthProviders(): Promise<void> {
|
||||
if (
|
||||
!config.oauth.enabled ||
|
||||
!config.oauth.clientId ||
|
||||
!config.oauth.clientSecret
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = config.oauth.providerName;
|
||||
const existing = await getOAuthProviderByName(name);
|
||||
|
||||
const data = {
|
||||
type: "oidc" as const,
|
||||
clientId: config.oauth.clientId,
|
||||
clientSecret: config.oauth.clientSecret,
|
||||
issuer: config.oauth.issuer ?? null,
|
||||
authorizationUrl: config.oauth.authorizationUrl ?? null,
|
||||
tokenUrl: config.oauth.tokenUrl ?? null,
|
||||
userinfoUrl: config.oauth.userinfoUrl ?? null,
|
||||
autoLink: config.oauth.allowAutoLinking,
|
||||
};
|
||||
|
||||
if (existing && existing.source === "env") {
|
||||
// Update existing env-sourced provider
|
||||
await updateOAuthProvider(existing.id, { name, ...data });
|
||||
} else if (!existing) {
|
||||
// Create new env-sourced provider
|
||||
await createOAuthProvider({ name, ...data, source: "env" });
|
||||
}
|
||||
// If a UI-sourced provider with the same name exists, don't overwrite it
|
||||
}
|
||||
Reference in New Issue
Block a user