Add Authentik OIDC integration (task 06)

- next-auth@beta + @auth/drizzle-adapter wired up with database sessions
- src/lib/auth.ts: OIDC provider, authorized callback, signIn household-attach, getCurrentUser()
- src/middleware.ts: protects all routes except /login, /s/*, /api/auth/*
- src/app/api/auth/[...nextauth]/route.ts: mounts Auth.js handlers
- src/app/login/page.tsx: single SSO sign-in button (server action)
- Schema: users extended (name/emailVerified/image), accounts/sessions/verificationTokens added
- drizzle/0001_auth_tables.sql: migration for schema changes
- deploy/authentik/README.md: manual bootstrap steps for Authentik
- src/lib/db.ts: pass schema to drizzle for relational query builder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ginnoir
2026-05-06 02:37:41 -05:00
co-authored by Claude Sonnet 4.6
parent f59753404e
commit 5da472d6ff
11 changed files with 392 additions and 4 deletions
+88
View File
@@ -0,0 +1,88 @@
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import NextAuth, { type DefaultSession } from "next-auth";
import { db } from "@/lib/db";
import {
accounts,
households,
householdMembers,
sessions,
users,
verificationTokens,
} from "@/modules/_core/schema";
import { eq } from "drizzle-orm";
declare module "next-auth" {
interface Session {
user: { id: string } & DefaultSession["user"];
}
}
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}),
session: { strategy: "database" },
providers: [
{
id: "authentik",
name: "Authentik",
type: "oidc",
issuer: process.env.AUTH_OIDC_ISSUER,
clientId: process.env.AUTH_OIDC_CLIENT_ID,
clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET,
},
],
pages: {
signIn: "/login",
},
callbacks: {
async signIn({ user }) {
if (!user.id) return true;
// Attach user to the single household if not already a member.
// (Household is seeded by task 07; this is a no-op until then.)
const existing = await db
.select()
.from(householdMembers)
.where(eq(householdMembers.userId, user.id))
.limit(1);
if (existing.length === 0) {
const [household] = await db.select().from(households).limit(1);
if (household) {
await db
.insert(householdMembers)
.values({ householdId: household.id, userId: user.id })
.onConflictDoNothing();
}
}
return true;
},
session({ session, user }) {
session.user.id = user.id;
return session;
},
authorized({ auth: session, request: { nextUrl } }) {
const isLoggedIn = !!session?.user;
const { pathname } = nextUrl;
if (
pathname.startsWith("/s/") ||
pathname.startsWith("/api/auth/") ||
pathname === "/login"
) {
return true;
}
return isLoggedIn;
},
},
});
export async function getCurrentUser() {
const session = await auth();
if (!session?.user?.id) return null;
const user = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.id, session.user.id),
});
return user ?? null;
}