Added user tab and oauth2, streamlined readme

This commit is contained in:
fuomag9
2025-12-28 15:14:56 +01:00
parent f8a673cc03
commit be21f46ad5
28 changed files with 3213 additions and 245 deletions
+294 -3
View File
@@ -1,20 +1,27 @@
import NextAuth, { type DefaultSession } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import type { OAuthConfig } from "next-auth/providers";
import bcrypt from "bcryptjs";
import { cookies } from "next/headers";
import db from "./db";
import { config } from "./config";
import { users } from "./db/schema";
import { findUserByProviderSubject, findUserByEmail, createUser, getUserById } from "./models/user";
import { createAuditEvent } from "./models/audit";
import { decideLinkingStrategy, createLinkingToken, autoLinkOAuth, linkOAuthAuthenticated } from "./services/account-linking";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: string;
provider?: string;
} & DefaultSession["user"];
}
interface User {
role?: string;
provider?: string;
}
}
@@ -63,8 +70,43 @@ function createCredentialsProvider() {
const credentialsProvider = createCredentialsProvider();
// Create OAuth providers based on configuration
function createOAuthProviders(): OAuthConfig<any>[] {
const providers: OAuthConfig<any>[] = [];
if (
config.oauth.enabled &&
config.oauth.clientId &&
config.oauth.clientSecret
) {
const oauthProvider: OAuthConfig<any> = {
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,
checks: ["state"],
profile(profile) {
return {
id: profile.sub ?? profile.id,
name: profile.name ?? profile.preferred_username ?? profile.email,
email: profile.email,
image: profile.picture ?? profile.avatar_url ?? null,
};
},
};
providers.push(oauthProvider);
}
return providers;
}
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [credentialsProvider],
providers: [credentialsProvider, ...createOAuthProviders()],
session: {
strategy: "jwt",
maxAge: 7 * 24 * 60 * 60, // 7 days
@@ -73,20 +115,269 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
signIn: "/login",
},
callbacks: {
async jwt({ token, user }) {
async signIn({ user, account, profile }) {
// 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, and, gt } = 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) {
console.error(`OAuth linking rejected: user email mismatch. Expected ${pendingLink.userEmail}, got ${existingUser.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 while authenticated: ${account.provider}`,
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: `User signed in via ${account.provider}`,
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: ${account.provider}`,
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
);
// Redirect to link-account page with token
throw new Error(`LINKING_REQUIRED:${linkingToken}`);
}
// New OAuth user - create account (defaults to admin role)
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 created via ${account.provider} OAuth`,
data: JSON.stringify({ provider: account.provider, email: user.email })
});
return true;
} catch (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;
}
},
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) {
if (session.user && token.id) {
session.user.id = token.id as string;
session.user.role = token.role as string;
session.user.provider = token.provider as string;
// Fetch current avatar from database to ensure it's always up-to-date
const userId = Number(token.id);
const currentUser = await getUserById(userId);
session.user.image = currentUser?.avatar_url ?? (token.image as string | null | undefined);
}
return session;
},
+33
View File
@@ -157,6 +157,17 @@ export const config = {
},
get adminPassword() {
return getAdminCredentials().password;
},
oauth: {
enabled: process.env.OAUTH_ENABLED === "true",
providerName: process.env.OAUTH_PROVIDER_NAME ?? "OAuth2",
clientId: process.env.OAUTH_CLIENT_ID ?? null,
clientSecret: process.env.OAUTH_CLIENT_SECRET ?? null,
issuer: process.env.OAUTH_ISSUER ?? null,
authorizationUrl: process.env.OAUTH_AUTHORIZATION_URL ?? null,
tokenUrl: process.env.OAUTH_TOKEN_URL ?? null,
userinfoUrl: process.env.OAUTH_USERINFO_URL ?? null,
allowAutoLinking: process.env.OAUTH_ALLOW_AUTO_LINKING === "true",
}
};
@@ -174,3 +185,25 @@ export function validateProductionConfig() {
const ___ = config.adminPassword;
}
}
/**
* Returns list of enabled OAuth providers based on configuration.
* Only includes providers that have complete credentials configured.
*/
export function getEnabledOAuthProviders(): Array<{id: string; name: string}> {
const providers: Array<{id: string; name: string}> = [];
if (
config.oauth.enabled &&
config.oauth.clientId &&
config.oauth.clientSecret &&
config.oauth.issuer
) {
providers.push({
id: "oauth2",
name: config.oauth.providerName
});
}
return providers;
}
+12
View File
@@ -52,6 +52,18 @@ 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" }),
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()
}, (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)
}));
export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
+20 -1
View File
@@ -1,4 +1,4 @@
import db, { toIso } from "../db";
import db, { toIso, nowIso } from "../db";
import { auditEvents } from "../db/schema";
import { desc } from "drizzle-orm";
@@ -29,3 +29,22 @@ export async function listAuditEvents(limit = 100): Promise<AuditEvent[]> {
created_at: toIso(event.createdAt)!
}));
}
export async function createAuditEvent(data: {
userId: number | null;
action: string;
entityType: string;
entityId?: number | null;
summary?: string | null;
data?: string | null;
}): Promise<void> {
await db.insert(auditEvents).values({
userId: data.userId,
action: data.action,
entityType: data.entityType,
entityId: data.entityId ?? null,
summary: data.summary ?? null,
data: data.data ?? null,
createdAt: nowIso(),
});
}
+1 -1
View File
@@ -71,7 +71,7 @@ export async function createUser(data: {
passwordHash?: string | null;
}): Promise<User> {
const now = nowIso();
const role = data.role ?? "user";
const role = data.role ?? "admin"; // All users are admin by default
const email = data.email.trim().toLowerCase();
const [user] = await db
+219
View File
@@ -0,0 +1,219 @@
import bcrypt from "bcryptjs";
import { SignJWT, jwtVerify } from "jose";
import { config } from "../config";
import { findUserByEmail, findUserByProviderSubject, getUserById } from "../models/user";
import db from "../db";
import { users } from "../db/schema";
import { eq } from "drizzle-orm";
import { nowIso } from "../db";
const LINKING_TOKEN_EXPIRY = 5 * 60; // 5 minutes in seconds
export type LinkingDecision = {
action: "auto_link" | "require_manual_link" | "create_new" | "signin_existing";
userId?: number;
reason: string;
};
export type LinkingTokenPayload = {
userId: number;
provider: string;
providerAccountId: string;
email: string;
exp: number;
};
/**
* Determines how to handle an OAuth sign-in attempt
*/
export async function decideLinkingStrategy(
provider: string,
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 if email matches existing user
const existingEmailUser = await findUserByEmail(email);
if (!existingEmailUser) {
return {
action: "create_new",
reason: "No existing account with this email"
};
}
// User exists with this email
if (existingEmailUser.password_hash) {
// Has password - require manual linking with password verification
return {
action: "require_manual_link",
userId: existingEmailUser.id,
reason: "Account has password - requires manual linking"
};
}
// No password (OAuth-only account)
if (config.oauth.allowAutoLinking) {
return {
action: "auto_link",
userId: existingEmailUser.id,
reason: "Account has no password - auto-linking enabled"
};
}
return {
action: "require_manual_link",
userId: existingEmailUser.id,
reason: "Auto-linking disabled"
};
}
/**
* Create a temporary linking token (5-minute expiry)
*/
export async function createLinkingToken(
userId: number,
provider: string,
providerAccountId: string,
email: string
): Promise<string> {
const secret = new TextEncoder().encode(config.sessionSecret);
const token = await new SignJWT({
userId,
provider,
providerAccountId,
email
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime(`${LINKING_TOKEN_EXPIRY}s`)
.setIssuedAt()
.sign(secret);
return token;
}
/**
* Verify and decode linking token
*/
export async function verifyLinkingToken(token: string): Promise<LinkingTokenPayload | null> {
try {
const secret = new TextEncoder().encode(config.sessionSecret);
const { payload } = await jwtVerify(token, secret);
return {
userId: payload.userId as number,
provider: payload.provider as string,
providerAccountId: payload.providerAccountId as string,
email: payload.email as string,
exp: payload.exp as number
};
} catch (error) {
console.error("Token verification failed:", error);
return null;
}
}
/**
* Verify password and link OAuth account to existing user
*/
export async function verifyAndLinkOAuth(
userId: number,
password: string,
provider: string,
providerAccountId: string
): Promise<boolean> {
const user = await getUserById(userId);
if (!user || !user.password_hash) {
return false;
}
// Verify password
const isValid = bcrypt.compareSync(password, user.password_hash);
if (!isValid) {
return false;
}
// Update user to link OAuth
await db
.update(users)
.set({
provider,
subject: providerAccountId,
updatedAt: nowIso()
})
.where(eq(users.id, userId));
return true;
}
/**
* Auto-link OAuth account (for users without passwords)
*/
export async function autoLinkOAuth(
userId: number,
provider: string,
providerAccountId: string,
avatarUrl?: string | null
): Promise<boolean> {
const user = await getUserById(userId);
if (!user) {
return false;
}
// 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 && !process.env.OAUTH_ALLOW_AUTO_LINKING) {
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));
return true;
}
/**
* Link OAuth account for an already-authenticated user
* This bypasses the password check since the user is already authenticated
*/
export async function linkOAuthAuthenticated(
userId: number,
provider: string,
providerAccountId: string,
avatarUrl?: string | null
): Promise<boolean> {
const user = await getUserById(userId);
if (!user) {
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));
return true;
}