implement oauth2 login

This commit is contained in:
fuomag9
2025-10-31 23:02:30 +01:00
parent 29acf06f75
commit d9ced96e1b
29 changed files with 800 additions and 136 deletions

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

@@ -0,0 +1,215 @@
import NextAuth, { type DefaultSession } from "next-auth";
import Authentik from "next-auth/providers/authentik";
import { CustomAdapter } from "./auth/adapter";
import { getOAuthSettings } from "./settings";
import { config } from "./config";
import type { SessionContext, UserRecord } from "./auth/session";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: string;
} & DefaultSession["user"];
}
interface User {
role?: string;
}
}
// Legacy compatibility types
export type { SessionContext, UserRecord };
/**
* Creates the appropriate OAuth provider based on settings.
*/
function createOAuthProvider() {
const settings = getOAuthSettings();
if (!settings) {
return null;
}
// Use official Authentik provider for OIDC
if (settings.providerType === "authentik") {
// Extract issuer from authorization URL
// Authentik format: https://domain/application/o/APP_SLUG/authorization/authorize/
// Issuer should be: https://domain/application/o/APP_SLUG/
let issuer: string;
try {
const url = new URL(settings.authorizationUrl);
const pathParts = url.pathname.split('/').filter(Boolean);
const oIndex = pathParts.indexOf('o');
if (oIndex >= 0 && pathParts[oIndex + 2] === 'authorization') {
const slug = pathParts[oIndex + 1];
issuer = `${url.origin}/application/o/${slug}/`;
} else {
// Fallback: remove the authorization path
issuer = settings.authorizationUrl.replace(/\/authorization\/authorize\/?$/, '/');
}
console.log('[Auth.js] Derived Authentik issuer:', issuer);
console.log('[Auth.js] Will attempt OIDC discovery at:', `${issuer}.well-known/openid-configuration`);
} catch (e) {
console.error("Failed to parse Authentik issuer from URL", e);
return null;
}
return Authentik({
clientId: settings.clientId,
clientSecret: settings.clientSecret,
issuer,
authorization: {
params: {
scope: settings.scopes || "openid email profile",
},
},
});
}
// Generic OAuth2 provider for non-OIDC providers
return {
id: "oauth",
name: "OAuth2",
type: "oauth" as const,
authorization: {
url: settings.authorizationUrl,
params: {
scope: settings.scopes || "openid email profile",
},
},
token: {
url: settings.tokenUrl,
},
userinfo: {
url: settings.userInfoUrl,
},
clientId: settings.clientId,
clientSecret: settings.clientSecret,
checks: ["state", "pkce"] as const,
profile(profile: any) {
const emailClaim = settings.emailClaim || "email";
const nameClaim = settings.nameClaim || "name";
const avatarClaim = settings.avatarClaim || "picture";
return {
id: String(profile.sub || profile.id || profile.user_id || profile[emailClaim]),
email: String(profile[emailClaim]),
name: profile[nameClaim] ? String(profile[nameClaim]) : null,
image: profile[avatarClaim] ? String(profile[avatarClaim]) : null,
};
},
};
}
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: CustomAdapter(),
providers: [createOAuthProvider()].filter(Boolean),
session: {
strategy: "database",
maxAge: 7 * 24 * 60 * 60, // 7 days
},
pages: {
signIn: "/login",
},
callbacks: {
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
// Fetch role from database
const db = (await import("./db")).default;
const dbUser = db.prepare("SELECT role FROM users WHERE id = ?").get(user.id) as { role: string } | undefined;
session.user.role = dbUser?.role || "user";
}
return session;
},
async signIn({ user, account, profile }) {
// Auto-assign admin role to first user
const db = (await import("./db")).default;
const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as { count: number };
if (userCount.count === 1) {
// This is the first user, make them admin
db.prepare("UPDATE users SET role = ? WHERE id = ?").run("admin", user.id);
}
return true;
},
async redirect({ url, baseUrl }) {
// Validate redirect URL to prevent open redirect attacks
if (url.startsWith("/")) {
// Reject URLs starting with // (protocol-relative URLs)
if (url.startsWith("//")) {
return baseUrl;
}
// Check for encoded slashes
if (url.includes('%2f%2f') || url.toLowerCase().includes('%2f%2f')) {
return baseUrl;
}
// Reject protocol specifications in the path
if (/^\/[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) {
return baseUrl;
}
return url;
}
// Only allow redirects to same origin
if (url.startsWith(baseUrl)) {
return url;
}
return baseUrl;
},
},
secret: config.sessionSecret,
trustHost: true,
basePath: "/api/auth",
});
/**
* Helper function to get the current session on the server.
* Returns user and session data in the legacy format for compatibility.
*/
export async function getSessionLegacy(): Promise<SessionContext | null> {
const session = await auth();
if (!session?.user) {
return null;
}
const db = (await import("./db")).default;
const user = db.prepare(
`SELECT id, email, name, role, provider, subject, avatar_url, status, created_at, updated_at
FROM users WHERE id = ?`
).get(session.user.id) as UserRecord | undefined;
if (!user) {
return null;
}
return {
session: {
id: 0, // Auth.js doesn't expose session ID
user_id: Number(session.user.id),
token: "", // Not exposed by Auth.js
expires_at: session.expires || "",
created_at: ""
},
user
};
}
/**
* Helper function to require authentication, throwing if not authenticated.
* Returns user and session data in the legacy format for compatibility.
*/
export async function requireUser(): Promise<SessionContext> {
const context = await getSessionLegacy();
if (!context) {
const { redirect } = await import("next/navigation");
redirect("/login");
// TypeScript doesn't know redirect() never returns, so we throw to help the type checker
throw new Error("Redirecting to login");
}
return context;
}

205
src/lib/auth/adapter.ts Normal file
View File

@@ -0,0 +1,205 @@
import type { Adapter, AdapterUser, AdapterAccount, AdapterSession, VerificationToken } from "next-auth/adapters";
import db, { nowIso } from "../db";
import crypto from "node:crypto";
/**
* Custom Auth.js adapter for our existing SQLite database schema.
* Maps our existing users/sessions tables to Auth.js expectations.
*/
export function CustomAdapter(): Adapter {
return {
async createUser(user: Omit<AdapterUser, "id">): Promise<AdapterUser> {
const stmt = db.prepare(
`INSERT INTO users (email, name, avatar_url, provider, subject, role, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
// For Auth.js, we'll use 'oidc' as provider and email as subject initially
const subject = crypto.randomBytes(16).toString("hex");
const info = stmt.run(
user.email,
user.name || null,
user.image || null,
"oidc",
subject,
"user",
"active",
nowIso(),
nowIso()
);
return {
id: String(info.lastInsertRowid),
email: user.email,
emailVerified: user.emailVerified || null,
name: user.name || null,
image: user.image || null
};
},
async getUser(id: string): Promise<AdapterUser | null> {
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as any;
if (!user) return null;
return {
id: String(user.id),
email: user.email,
emailVerified: null,
name: user.name,
image: user.avatar_url
};
},
async getUserByEmail(email: string): Promise<AdapterUser | null> {
const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email) as any;
if (!user) return null;
return {
id: String(user.id),
email: user.email,
emailVerified: null,
name: user.name,
image: user.avatar_url
};
},
async getUserByAccount({ providerAccountId, provider }): Promise<AdapterUser | null> {
// For Authentik OIDC, match by subject (sub claim)
const user = db.prepare(
"SELECT * FROM users WHERE subject = ?"
).get(providerAccountId) as any;
if (!user) return null;
return {
id: String(user.id),
email: user.email,
emailVerified: null,
name: user.name,
image: user.avatar_url
};
},
async updateUser(user: Partial<AdapterUser> & Pick<AdapterUser, "id">): Promise<AdapterUser> {
const existing = db.prepare("SELECT * FROM users WHERE id = ?").get(user.id) as any;
db.prepare(
`UPDATE users SET email = ?, name = ?, avatar_url = ?, updated_at = ?
WHERE id = ?`
).run(
user.email || existing.email,
user.name || existing.name,
user.image || existing.avatar_url,
nowIso(),
user.id
);
return {
id: user.id,
email: user.email || existing.email,
emailVerified: user.emailVerified || null,
name: user.name || existing.name,
image: user.image || existing.avatar_url
};
},
async deleteUser(userId: string): Promise<void> {
db.prepare("DELETE FROM users WHERE id = ?").run(userId);
},
async linkAccount(account: AdapterAccount): Promise<AdapterAccount | null | undefined> {
// Update the user's subject to the OIDC sub claim
db.prepare(
`UPDATE users SET subject = ?, updated_at = ?
WHERE id = ?`
).run(account.providerAccountId, nowIso(), account.userId);
return account;
},
async unlinkAccount({ providerAccountId, provider }): Promise<void> {
// Set subject back to random
db.prepare(
`UPDATE users SET subject = ?, updated_at = ?
WHERE subject = ?`
).run(crypto.randomBytes(16).toString("hex"), nowIso(), providerAccountId);
},
async createSession({ sessionToken, userId, expires }): Promise<AdapterSession> {
const expiresAt = expires.toISOString();
db.prepare(
`INSERT INTO sessions (user_id, token, expires_at, created_at)
VALUES (?, ?, ?, ?)`
).run(userId, sessionToken, expiresAt, nowIso());
return {
sessionToken,
userId,
expires
};
},
async getSessionAndUser(sessionToken: string): Promise<{ session: AdapterSession; user: AdapterUser } | null> {
const result = db.prepare(
`SELECT s.token, s.user_id, s.expires_at, u.id, u.email, u.name, u.avatar_url
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.token = ?`
).get(sessionToken) as any;
if (!result) return null;
const expires = new Date(result.expires_at);
if (expires.getTime() < Date.now()) {
db.prepare("DELETE FROM sessions WHERE token = ?").run(sessionToken);
return null;
}
return {
session: {
sessionToken: result.token,
userId: String(result.user_id),
expires
},
user: {
id: String(result.id),
email: result.email,
emailVerified: null,
name: result.name,
image: result.avatar_url
}
};
},
async updateSession(session: Partial<AdapterSession> & Pick<AdapterSession, "sessionToken">): Promise<AdapterSession | null | undefined> {
if (session.expires) {
db.prepare(
"UPDATE sessions SET expires_at = ? WHERE token = ?"
).run(session.expires.toISOString(), session.sessionToken);
}
const existing = db.prepare("SELECT * FROM sessions WHERE token = ?").get(session.sessionToken) as any;
if (!existing) return null;
return {
sessionToken: session.sessionToken,
userId: String(existing.user_id),
expires: session.expires || new Date(existing.expires_at)
};
},
async deleteSession(sessionToken: string): Promise<void> {
db.prepare("DELETE FROM sessions WHERE token = ?").run(sessionToken);
},
// Verification tokens not currently used, but required by adapter interface
async createVerificationToken(token: VerificationToken): Promise<VerificationToken | null | undefined> {
return token;
},
async useVerificationToken({ identifier, token }): Promise<VerificationToken | null> {
return null;
}
};
}

View File

@@ -15,6 +15,25 @@ type TokenResponse = {
id_token?: string;
};
/**
* Validates that a redirect path is safe for internal redirection.
* Only allows paths that start with / but not //
* @param path - The path to validate
* @returns true if the path is safe, false otherwise
*/
function isValidRedirectPath(path: string): boolean {
if (!path) return false;
// Must start with / but not // (which could redirect to external site)
// Must not contain any protocol (http:, https:, ftp:, etc.)
if (!path.startsWith('/')) return false;
if (path.startsWith('//')) return false;
// Check for encoded slashes and protocols
if (path.includes('%2f%2f') || path.toLowerCase().includes('%2f%2f')) return false;
// Ensure no protocol specification
if (/^\/[a-zA-Z][a-zA-Z0-9+.-]*:/.test(path)) return false;
return true;
}
export function requireOAuthSettings(): OAuthSettings {
const settings = getOAuthSettings();
if (!settings) {
@@ -66,10 +85,22 @@ function consumeOAuthState(state: string): { codeVerifier: string; redirectTo: s
export function buildAuthorizationUrl(redirectTo?: string): string {
const settings = requireOAuthSettings();
// Validate redirectTo parameter to prevent open redirect attacks
let safeRedirectTo: string | undefined;
if (redirectTo) {
if (!isValidRedirectPath(redirectTo)) {
console.warn(`Invalid redirectTo parameter rejected: ${redirectTo}`);
safeRedirectTo = undefined;
} else {
safeRedirectTo = redirectTo;
}
}
const state = crypto.randomBytes(24).toString("base64url");
const verifier = createCodeVerifier();
const challenge = codeChallengeFromVerifier(verifier);
storeOAuthState(state, verifier, redirectTo);
storeOAuthState(state, verifier, safeRedirectTo);
const redirectUri = `${config.baseUrl}/api/auth/callback`;
const url = new URL(settings.authorizationUrl);

View File

@@ -10,11 +10,7 @@ const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
type CookiesHandle = Awaited<ReturnType<typeof cookies>>;
async function getCookieStore(): Promise<CookiesHandle> {
const store = cookies();
if (typeof (store as any)?.then === "function") {
return (await store) as CookiesHandle;
}
return store as CookiesHandle;
return (await cookies()) as CookiesHandle;
}
function hashToken(token: string): string {
@@ -67,8 +63,8 @@ export async function createSession(userId: number): Promise<SessionRecord> {
};
const cookieStore = await getCookieStore();
if (typeof (cookieStore as any).set === "function") {
(cookieStore as any).set({
if (typeof cookieStore.set === "function") {
cookieStore.set({
name: SESSION_COOKIE,
value: token,
httpOnly: true,
@@ -86,9 +82,9 @@ export async function createSession(userId: number): Promise<SessionRecord> {
export async function destroySession() {
const cookieStore = await getCookieStore();
const token = typeof (cookieStore as any).get === "function" ? cookieStore.get(SESSION_COOKIE) : undefined;
if (typeof (cookieStore as any).delete === "function") {
(cookieStore as any).delete(SESSION_COOKIE);
const token = typeof cookieStore.get === "function" ? cookieStore.get(SESSION_COOKIE) : undefined;
if (typeof cookieStore.delete === "function") {
cookieStore.delete(SESSION_COOKIE);
}
if (!token) {
@@ -101,7 +97,7 @@ export async function destroySession() {
export async function getSession(): Promise<SessionContext | null> {
const cookieStore = await getCookieStore();
const token = typeof (cookieStore as any).get === "function" ? cookieStore.get(SESSION_COOKIE) : undefined;
const token = typeof cookieStore.get === "function" ? cookieStore.get(SESSION_COOKIE) : undefined;
if (!token) {
return null;
}
@@ -116,17 +112,11 @@ export async function getSession(): Promise<SessionContext | null> {
.get(hashed) as SessionRecord | undefined;
if (!session) {
if (typeof (cookieStore as any).delete === "function") {
(cookieStore as any).delete(SESSION_COOKIE);
}
return null;
}
if (new Date(session.expires_at).getTime() < Date.now()) {
db.prepare("DELETE FROM sessions WHERE id = ?").run(session.id);
if (typeof (cookieStore as any).delete === "function") {
(cookieStore as any).delete(SESSION_COOKIE);
}
return null;
}
@@ -138,9 +128,6 @@ export async function getSession(): Promise<SessionContext | null> {
.get(session.user_id) as UserRecord | undefined;
if (!user || user.status !== "active") {
if (typeof (cookieStore as any).delete === "function") {
(cookieStore as any).delete(SESSION_COOKIE);
}
return null;
}

View File

@@ -178,6 +178,28 @@ const MIGRATIONS: Migration[] = [
);
`);
}
},
{
id: 2,
description: "add provider type to OAuth settings",
up: (db) => {
// Add providerType field to existing OAuth settings
// Default to 'authentik' for existing installations since that's what we're supporting
const settings = db.prepare("SELECT value FROM settings WHERE key = 'oauth'").get() as { value: string } | undefined;
if (settings) {
try {
const oauth = JSON.parse(settings.value);
// Only update if providerType doesn't exist
if (!oauth.providerType) {
oauth.providerType = 'authentik';
db.prepare("UPDATE settings SET value = ? WHERE key = 'oauth'").run(JSON.stringify(oauth));
}
} catch (e) {
console.error("Failed to migrate OAuth settings:", e);
}
}
}
}
];

View File

@@ -3,6 +3,7 @@ import db, { nowIso } from "./db";
export type SettingValue<T> = T | null;
export type OAuthSettings = {
providerType: "authentik" | "generic";
authorizationUrl: string;
tokenUrl: string;
clientId: string;