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:
co-authored by
Claude Sonnet 4.6
parent
f59753404e
commit
5da472d6ff
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { signIn } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-8">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<h1 className="text-4xl font-bold">famapp</h1>
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signIn("authentik", { redirectTo: "/" });
|
||||
}}
|
||||
>
|
||||
<Button type="submit" size="lg">
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "@/modules/_core/schema";
|
||||
|
||||
const client = postgres(process.env["DATABASE_URL"]!);
|
||||
|
||||
export const db = drizzle(client);
|
||||
export const db = drizzle(client, { schema });
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export { auth as middleware } from "@/lib/auth";
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Skip Next.js internals and static files
|
||||
"/((?!_next/static|_next/image|favicon.ico).*)",
|
||||
],
|
||||
};
|
||||
@@ -1,14 +1,66 @@
|
||||
import { pgEnum, pgTable, primaryKey, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
|
||||
import type { AdapterAccountType } from "@auth/core/adapters";
|
||||
import {
|
||||
integer,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const roleEnum = pgEnum("household_member_role", ["owner", "member"]);
|
||||
|
||||
// Auth.js-compatible users table. "name" and "image" are populated from OIDC claims.
|
||||
export const users = pgTable("users", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name"),
|
||||
email: varchar("email", { length: 255 }).notNull().unique(),
|
||||
displayName: varchar("display_name", { length: 255 }).notNull(),
|
||||
emailVerified: timestamp("email_verified", { withTimezone: true }),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// Auth.js adapter tables
|
||||
export const accounts = pgTable(
|
||||
"accounts",
|
||||
{
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
type: text("type").$type<AdapterAccountType>().notNull(),
|
||||
provider: text("provider").notNull(),
|
||||
providerAccountId: text("provider_account_id").notNull(),
|
||||
refresh_token: text("refresh_token"),
|
||||
access_token: text("access_token"),
|
||||
expires_at: integer("expires_at"),
|
||||
token_type: text("token_type"),
|
||||
scope: text("scope"),
|
||||
id_token: text("id_token"),
|
||||
session_state: text("session_state"),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.provider, t.providerAccountId] })],
|
||||
);
|
||||
|
||||
export const sessions = pgTable("sessions", {
|
||||
sessionToken: text("session_token").primaryKey(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
expires: timestamp("expires", { withTimezone: true }).notNull(),
|
||||
});
|
||||
|
||||
export const verificationTokens = pgTable(
|
||||
"verification_tokens",
|
||||
{
|
||||
identifier: text("identifier").notNull(),
|
||||
token: text("token").notNull(),
|
||||
expires: timestamp("expires", { withTimezone: true }).notNull(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.identifier, t.token] })],
|
||||
);
|
||||
|
||||
export const households = pgTable("households", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
|
||||
Reference in New Issue
Block a user