implement oauth2 login
This commit is contained in:
215
src/lib/auth.ts
Normal file
215
src/lib/auth.ts
Normal 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
205
src/lib/auth/adapter.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user