Added Better Auth

This commit is contained in:
Arunavo Ray
2025-07-10 23:15:37 +05:30
parent 46cf117bdf
commit b838310872
34 changed files with 2573 additions and 175 deletions

22
src/lib/auth-client.ts Normal file
View File

@@ -0,0 +1,22 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
// The base URL is optional when running on the same domain
// Better Auth will use the current domain by default
});
// Export commonly used methods for convenience
export const {
signIn,
signUp,
signOut,
useSession,
sendVerificationEmail,
resetPassword,
requestPasswordReset,
getSession
} = authClient;
// Export types
export type Session = Awaited<ReturnType<typeof authClient.getSession>>["data"];
export type AuthUser = Session extends { user: infer U } ? U : never;

76
src/lib/auth-config.ts Normal file
View File

@@ -0,0 +1,76 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { sso, oidcProvider } from "better-auth/plugins";
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
// Generate or use existing JWT secret
const JWT_SECRET = process.env.JWT_SECRET || process.env.BETTER_AUTH_SECRET;
if (!JWT_SECRET) {
throw new Error("JWT_SECRET or BETTER_AUTH_SECRET environment variable is required");
}
// This function will be called with the actual database instance
export function createAuth(db: BunSQLiteDatabase) {
return betterAuth({
// Database configuration
database: drizzleAdapter(db, {
provider: "sqlite",
usePlural: true, // Our tables use plural names (users, not user)
}),
// Base URL configuration
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
// Authentication methods
emailAndPassword: {
enabled: true,
requireEmailVerification: false, // We'll enable this later
sendResetPassword: async ({ user, url, token }, request) => {
// TODO: Implement email sending for password reset
console.log("Password reset requested for:", user.email);
console.log("Reset URL:", url);
},
},
// Session configuration
session: {
cookieName: "better-auth-session",
updateSessionCookieAge: true,
expiresIn: 60 * 60 * 24 * 30, // 30 days
},
// User configuration
user: {
additionalFields: {
// We can add custom fields here if needed
},
},
// Plugins for future OIDC/SSO support
plugins: [
// SSO plugin for OIDC client support
sso({
provisionUser: async (data) => {
// Custom user provisioning logic for SSO users
console.log("Provisioning SSO user:", data);
return data;
},
}),
// OIDC Provider plugin (for future use when we want to be an OIDC provider)
oidcProvider({
loginPage: "/signin",
consentPage: "/oauth/consent",
metadata: {
issuer: process.env.BETTER_AUTH_URL || "http://localhost:3000",
},
}),
],
// Trusted origins for CORS
trustedOrigins: [
process.env.BETTER_AUTH_URL || "http://localhost:3000",
],
});
}

View File

@@ -0,0 +1,179 @@
/**
* Example OIDC/SSO Configuration for Better Auth
*
* This file demonstrates how to enable OIDC and SSO features in Gitea Mirror.
* To use: Copy this file to auth-oidc-config.ts and update the auth.ts import.
*/
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { sso } from "better-auth/plugins/sso";
import { oidcProvider } from "better-auth/plugins/oidc";
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
export function createAuthWithOIDC(db: BunSQLiteDatabase) {
return betterAuth({
// Database configuration
database: drizzleAdapter(db, {
provider: "sqlite",
usePlural: true,
}),
// Base configuration
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
basePath: "/api/auth",
// Email/Password authentication
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
},
// Session configuration
session: {
cookieName: "better-auth-session",
updateSessionCookieAge: true,
expiresIn: 60 * 60 * 24 * 30, // 30 days
},
// User configuration with additional fields
user: {
additionalFields: {
username: {
type: "string",
required: true,
defaultValue: "user",
input: true,
}
},
},
// OAuth2 providers (examples)
socialProviders: {
github: {
enabled: !!process.env.GITHUB_OAUTH_CLIENT_ID,
clientId: process.env.GITHUB_OAUTH_CLIENT_ID!,
clientSecret: process.env.GITHUB_OAUTH_CLIENT_SECRET!,
},
google: {
enabled: !!process.env.GOOGLE_OAUTH_CLIENT_ID,
clientId: process.env.GOOGLE_OAUTH_CLIENT_ID!,
clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!,
},
},
// Plugins
plugins: [
// SSO Plugin - For OIDC/SAML client functionality
sso({
// Auto-provision users from SSO providers
provisionUser: async (data) => {
console.log("Provisioning SSO user:", data.email);
// Custom logic to set username from email
const username = data.email.split('@')[0];
return {
...data,
username,
};
},
// Organization provisioning for enterprise SSO
organizationProvisioning: {
disabled: false,
defaultRole: "member",
getRole: async (user) => {
// Custom logic to determine user role
// For admin emails, grant admin role
if (user.email?.endsWith('@admin.example.com')) {
return 'admin';
}
return 'member';
},
},
}),
// OIDC Provider Plugin - Makes Gitea Mirror an OIDC provider
oidcProvider({
// Login page for OIDC authentication flow
loginPage: "/login",
// Consent page for OAuth2 authorization
consentPage: "/oauth/consent",
// Allow dynamic client registration
allowDynamicClientRegistration: false,
// OIDC metadata configuration
metadata: {
issuer: process.env.BETTER_AUTH_URL || "http://localhost:3000",
authorization_endpoint: "/api/auth/oauth2/authorize",
token_endpoint: "/api/auth/oauth2/token",
userinfo_endpoint: "/api/auth/oauth2/userinfo",
jwks_uri: "/api/auth/jwks",
},
// Additional user info claims
getAdditionalUserInfoClaim: (user, scopes) => {
const claims: Record<string, any> = {};
// Add custom claims based on scopes
if (scopes.includes('profile')) {
claims.username = user.username;
claims.preferred_username = user.username;
}
if (scopes.includes('gitea')) {
// Add Gitea-specific claims
claims.gitea_admin = false; // Customize based on your logic
claims.gitea_repos = []; // Could fetch user's repositories
}
return claims;
},
}),
],
// Trusted origins for CORS
trustedOrigins: [
process.env.BETTER_AUTH_URL || "http://localhost:3000",
// Add your OIDC client domains here
],
});
}
// Environment variables needed:
/*
# OAuth2 Providers (optional)
GITHUB_OAUTH_CLIENT_ID=your-github-client-id
GITHUB_OAUTH_CLIENT_SECRET=your-github-client-secret
GOOGLE_OAUTH_CLIENT_ID=your-google-client-id
GOOGLE_OAUTH_CLIENT_SECRET=your-google-client-secret
# SSO Configuration (when registering providers)
SSO_PROVIDER_ISSUER=https://idp.example.com
SSO_PROVIDER_CLIENT_ID=your-client-id
SSO_PROVIDER_CLIENT_SECRET=your-client-secret
*/
// Example: Registering an SSO provider programmatically
/*
import { authClient } from "./auth-client";
// Register corporate SSO
await authClient.sso.register({
issuer: "https://login.microsoftonline.com/tenant-id/v2.0",
domain: "company.com",
clientId: process.env.AZURE_CLIENT_ID!,
clientSecret: process.env.AZURE_CLIENT_SECRET!,
providerId: "azure-ad",
mapping: {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "name",
image: "picture",
},
});
*/

64
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,64 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db";
// Generate or use existing JWT secret
const JWT_SECRET = process.env.JWT_SECRET || process.env.BETTER_AUTH_SECRET;
if (!JWT_SECRET) {
throw new Error("JWT_SECRET or BETTER_AUTH_SECRET environment variable is required");
}
export const auth = betterAuth({
// Database configuration
database: drizzleAdapter(db, {
provider: "sqlite",
usePlural: true, // Our tables use plural names (users, not user)
}),
// Base URL configuration
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
basePath: "/api/auth", // Specify the base path for auth endpoints
// Authentication methods
emailAndPassword: {
enabled: true,
requireEmailVerification: false, // We'll enable this later
sendResetPassword: async ({ user, url, token }, request) => {
// TODO: Implement email sending for password reset
console.log("Password reset requested for:", user.email);
console.log("Reset URL:", url);
},
},
// Session configuration
session: {
cookieName: "better-auth-session",
updateSessionCookieAge: true,
expiresIn: 60 * 60 * 24 * 30, // 30 days
},
// User configuration
user: {
additionalFields: {
// Keep the username field from our existing schema
username: {
type: "string",
required: true,
defaultValue: "user", // Default for migration
input: true, // Allow in signup form
}
},
},
// TODO: Add plugins for SSO and OIDC support in the future
// plugins: [],
// Trusted origins for CORS
trustedOrigins: [
process.env.BETTER_AUTH_URL || "http://localhost:3000",
],
});
// Export type for use in other parts of the app
export type Auth = typeof auth;

View File

@@ -23,14 +23,14 @@ let sqlite: Database;
try {
sqlite = new Database(dbPath);
console.log("Successfully connected to SQLite database using Bun's native driver");
// Run Drizzle migrations if needed
runDrizzleMigrations();
} catch (error) {
console.error("Error opening database:", error);
throw error;
}
// Create drizzle instance with the SQLite client
export const db = drizzle({ client: sqlite });
/**
* Run Drizzle migrations
*/
@@ -57,8 +57,18 @@ function runDrizzleMigrations() {
}
}
// Create drizzle instance with the SQLite client
export const db = drizzle({ client: sqlite });
// Run Drizzle migrations after db is initialized
runDrizzleMigrations();
// Export all table definitions from schema
export { users, events, configs, repositories, mirrorJobs, organizations } from "./schema";
export {
users,
events,
configs,
repositories,
mirrorJobs,
organizations,
sessions,
accounts,
verificationTokens
} from "./schema";

View File

@@ -8,6 +8,7 @@ export const userSchema = z.object({
username: z.string(),
password: z.string(),
email: z.string().email(),
emailVerified: z.boolean().default(false),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
@@ -215,6 +216,7 @@ export const users = sqliteTable("users", {
username: text("username").notNull(),
password: text("password").notNull(),
email: text("email").notNull(),
emailVerified: integer("email_verified", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
@@ -434,6 +436,70 @@ export const organizations = sqliteTable("organizations", {
};
});
// ===== Better Auth Tables =====
// Sessions table
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
token: text("token").notNull().unique(),
userId: text("user_id").notNull().references(() => users.id),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}, (table) => {
return {
userIdIdx: index("idx_sessions_user_id").on(table.userId),
tokenIdx: index("idx_sessions_token").on(table.token),
expiresAtIdx: index("idx_sessions_expires_at").on(table.expiresAt),
};
});
// Accounts table (for OAuth providers and credentials)
export const accounts = sqliteTable("accounts", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id),
providerId: text("provider_id").notNull(),
providerUserId: text("provider_user_id").notNull(),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
expiresAt: integer("expires_at", { mode: "timestamp" }),
password: text("password"), // For credential provider
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}, (table) => {
return {
userIdIdx: index("idx_accounts_user_id").on(table.userId),
providerIdx: index("idx_accounts_provider").on(table.providerId, table.providerUserId),
};
});
// Verification tokens table
export const verificationTokens = sqliteTable("verification_tokens", {
id: text("id").primaryKey(),
token: text("token").notNull().unique(),
identifier: text("identifier").notNull(),
type: text("type").notNull(), // email, password-reset, etc
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}, (table) => {
return {
tokenIdx: index("idx_verification_tokens_token").on(table.token),
identifierIdx: index("idx_verification_tokens_identifier").on(table.identifier),
};
});
// Export type definitions
export type User = z.infer<typeof userSchema>;
export type Config = z.infer<typeof configSchema>;

View File

@@ -0,0 +1,58 @@
import type { APIRoute, APIContext } from "astro";
import { auth } from "@/lib/auth";
/**
* Get authenticated user from request
* @param request - The request object from Astro API route
* @returns The authenticated user or null if not authenticated
*/
export async function getAuthenticatedUser(request: Request) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
return session ? session.user : null;
} catch (error) {
console.error("Error getting session:", error);
return null;
}
}
/**
* Require authentication for API routes
* Returns an error response if user is not authenticated
* @param context - The API context from Astro
* @returns Object with user if authenticated, or error response if not
*/
export async function requireAuth(context: APIContext) {
const user = await getAuthenticatedUser(context.request);
if (!user) {
return {
user: null,
response: new Response(
JSON.stringify({
success: false,
error: "Unauthorized - Please log in",
}),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
),
};
}
return { user, response: null };
}
/**
* Get user ID from authenticated session
* @param request - The request object from Astro API route
* @returns The user ID or null if not authenticated
*/
export async function getAuthenticatedUserId(request: Request): Promise<string | null> {
const user = await getAuthenticatedUser(request);
return user?.id || null;
}