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

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;
},