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:
147
tests/integration/oauth-provider-sync.test.ts
Normal file
147
tests/integration/oauth-provider-sync.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { createTestDb, type TestDb } from "../helpers/db";
|
||||
import { oauthProviders } from "@/src/lib/db/schema";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { encryptSecret, decryptSecret } from "@/src/lib/secret";
|
||||
|
||||
let db: TestDb;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDb();
|
||||
});
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates what syncEnvOAuthProviders does:
|
||||
* - If no env-sourced provider with this name exists, create one
|
||||
* - If env-sourced provider exists, update it
|
||||
* - If UI-sourced provider with same name exists, skip
|
||||
*/
|
||||
async function syncProvider(envConfig: {
|
||||
name: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
issuer?: string | null;
|
||||
autoLink?: boolean;
|
||||
}) {
|
||||
const now = nowIso();
|
||||
const existing = await db.query.oauthProviders.findFirst({
|
||||
where: (table, { eq }) => eq(table.name, envConfig.name),
|
||||
});
|
||||
|
||||
if (existing && existing.source === "env") {
|
||||
// Update existing env-sourced provider
|
||||
const { eq } = await import("drizzle-orm");
|
||||
await db.update(oauthProviders).set({
|
||||
clientId: encryptSecret(envConfig.clientId),
|
||||
clientSecret: encryptSecret(envConfig.clientSecret),
|
||||
issuer: envConfig.issuer ?? null,
|
||||
autoLink: envConfig.autoLink ?? false,
|
||||
updatedAt: now,
|
||||
}).where(eq(oauthProviders.id, existing.id));
|
||||
} else if (!existing) {
|
||||
// Create new env-sourced provider
|
||||
await db.insert(oauthProviders).values({
|
||||
id: randomUUID(),
|
||||
name: envConfig.name,
|
||||
type: "oidc",
|
||||
clientId: encryptSecret(envConfig.clientId),
|
||||
clientSecret: encryptSecret(envConfig.clientSecret),
|
||||
issuer: envConfig.issuer ?? null,
|
||||
authorizationUrl: null,
|
||||
tokenUrl: null,
|
||||
userinfoUrl: null,
|
||||
scopes: "openid email profile",
|
||||
autoLink: envConfig.autoLink ?? false,
|
||||
enabled: true,
|
||||
source: "env",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
// If a UI-sourced provider with the same name exists, skip
|
||||
}
|
||||
|
||||
describe("syncEnvOAuthProviders", () => {
|
||||
it("creates env-sourced provider when configured", async () => {
|
||||
await syncProvider({
|
||||
name: "TestIdP",
|
||||
clientId: "env-client-id",
|
||||
clientSecret: "env-client-secret",
|
||||
issuer: "https://idp.example.com",
|
||||
});
|
||||
|
||||
const providers = await db.query.oauthProviders.findMany();
|
||||
expect(providers).toHaveLength(1);
|
||||
expect(providers[0].name).toBe("TestIdP");
|
||||
expect(providers[0].source).toBe("env");
|
||||
expect(decryptSecret(providers[0].clientId)).toBe("env-client-id");
|
||||
expect(providers[0].issuer).toBe("https://idp.example.com");
|
||||
});
|
||||
|
||||
it("updates existing env-sourced provider when config changes", async () => {
|
||||
// First sync
|
||||
await syncProvider({
|
||||
name: "MyIdP",
|
||||
clientId: "old-id",
|
||||
clientSecret: "old-secret",
|
||||
issuer: "https://old.example.com",
|
||||
autoLink: false,
|
||||
});
|
||||
|
||||
// Second sync with changed config
|
||||
await syncProvider({
|
||||
name: "MyIdP",
|
||||
clientId: "new-id",
|
||||
clientSecret: "new-secret",
|
||||
issuer: "https://new.example.com",
|
||||
autoLink: true,
|
||||
});
|
||||
|
||||
const providers = await db.query.oauthProviders.findMany();
|
||||
expect(providers).toHaveLength(1);
|
||||
expect(decryptSecret(providers[0].clientId)).toBe("new-id");
|
||||
expect(providers[0].issuer).toBe("https://new.example.com");
|
||||
expect(providers[0].autoLink).toBe(true);
|
||||
});
|
||||
|
||||
it("does not overwrite a UI-sourced provider with the same name", async () => {
|
||||
const now = nowIso();
|
||||
// Create a UI-sourced provider first
|
||||
await db.insert(oauthProviders).values({
|
||||
id: randomUUID(),
|
||||
name: "SharedName",
|
||||
type: "oidc",
|
||||
clientId: encryptSecret("ui-id"),
|
||||
clientSecret: encryptSecret("ui-secret"),
|
||||
scopes: "openid email profile",
|
||||
autoLink: false,
|
||||
enabled: true,
|
||||
source: "ui",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
// Try to sync env with the same name
|
||||
await syncProvider({
|
||||
name: "SharedName",
|
||||
clientId: "env-id",
|
||||
clientSecret: "env-secret",
|
||||
});
|
||||
|
||||
const providers = await db.query.oauthProviders.findMany();
|
||||
expect(providers).toHaveLength(1);
|
||||
// Should still be the UI provider, not overwritten
|
||||
expect(providers[0].source).toBe("ui");
|
||||
expect(decryptSecret(providers[0].clientId)).toBe("ui-id");
|
||||
});
|
||||
|
||||
it("skips when OAuth is not configured (no providers created)", async () => {
|
||||
// Simply don't call syncProvider - verify empty
|
||||
const providers = await db.query.oauthProviders.findMany();
|
||||
expect(providers).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user