feat(learner): Better Auth sessions, owner bootstrap, claim groups; server takes feature factories
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9aa475329a
commit
668ae379bf
@@ -0,0 +1,45 @@
|
||||
import type { Db } from "@stc/db";
|
||||
|
||||
export const groupIdFromName = (name: string): string =>
|
||||
name.trim().toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "") || "group";
|
||||
|
||||
/** Reads a groups claim from an IdP profile: an array, or a comma/space separated string. */
|
||||
export function groupsFromClaims(profile: Record<string, unknown> | null | undefined, claim = "groups"): string[] {
|
||||
if (!profile) return [];
|
||||
const raw = profile[claim] ?? (profile.extraFields as Record<string, unknown> | undefined)?.[claim];
|
||||
const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[,\s]+/) : [];
|
||||
return [...new Set(list.map((g) => String(g).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
/** Finds or creates the group called `name`; returns its id. */
|
||||
export async function ensureGroup(db: Db, name: string): Promise<string> {
|
||||
const byName = await db.sql`SELECT id FROM groups WHERE name = ${name}`;
|
||||
if (byName.length) return byName[0].id;
|
||||
let id = groupIdFromName(name);
|
||||
const taken = await db.sql`SELECT 1 AS x FROM groups WHERE id = ${id}`;
|
||||
if (taken.length) id = `${id}-${Bun.randomUUIDv7().slice(-6)}`;
|
||||
await db.sql`INSERT INTO groups (id, name) VALUES (${id}, ${name})`;
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the user's claim-sourced group memberships equal `names`. Memberships the owner added by hand
|
||||
* are never removed here, and a group the user already belongs to by hand is not recorded as a claim.
|
||||
*/
|
||||
export async function syncClaimGroups(db: Db, userId: string, names: string[]): Promise<void> {
|
||||
const wanted = new Set<string>();
|
||||
for (const n of names) wanted.add(await ensureGroup(db, n));
|
||||
const prior = new Set<string>((await db.sql`SELECT group_id FROM learner_group_claims WHERE user_id = ${userId}`).map((r: { group_id: string }) => r.group_id));
|
||||
for (const g of prior) {
|
||||
if (wanted.has(g)) continue;
|
||||
await db.sql`DELETE FROM learner_group_claims WHERE user_id = ${userId} AND group_id = ${g}`;
|
||||
await db.sql`DELETE FROM group_members WHERE user_id = ${userId} AND group_id = ${g}`;
|
||||
}
|
||||
for (const g of wanted) {
|
||||
if (prior.has(g)) continue;
|
||||
const member = await db.sql`SELECT 1 AS x FROM group_members WHERE group_id = ${g} AND user_id = ${userId}`;
|
||||
if (member.length) continue; // manual membership stays manual
|
||||
await db.sql`INSERT INTO group_members (group_id, user_id) VALUES (${g}, ${userId})`;
|
||||
await db.sql`INSERT INTO learner_group_claims (user_id, group_id) VALUES (${userId}, ${g})`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { betterAuth, type BetterAuthOptions } from "better-auth";
|
||||
import { genericOAuth } from "better-auth/plugins/generic-oauth";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import type { AuthService, AuthUser, Role, ServerContext } from "@stc/contracts";
|
||||
import type { Db } from "@stc/db";
|
||||
import { bunSqlAdapter } from "./adapter";
|
||||
import { groupsFromClaims, syncClaimGroups } from "./groups";
|
||||
|
||||
export interface ProviderInfo { id: string; label: string; kind: "social" | "oidc" }
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
const SOCIAL: { id: string; label: string; env: string }[] = [
|
||||
{ id: "github", label: "GitHub", env: "GITHUB" },
|
||||
{ id: "google", label: "Google", env: "GOOGLE" },
|
||||
{ id: "microsoft", label: "Microsoft", env: "MICROSOFT" },
|
||||
{ id: "gitlab", label: "GitLab", env: "GITLAB" },
|
||||
{ id: "discord", label: "Discord", env: "DISCORD" },
|
||||
];
|
||||
|
||||
/** Owner-only Better Auth paths (SSO provider administration). Sign-in and callbacks stay open. */
|
||||
const OWNER_ONLY = /^\/api\/auth\/sso\/(register|update-provider|delete-provider|providers|get-provider|request-domain-verification|verify-domain)\b/;
|
||||
|
||||
export async function rolesOf(db: Db, userId: string): Promise<Role[]> {
|
||||
const rows = await db.sql`SELECT role FROM user_roles WHERE user_id = ${userId} ORDER BY role`;
|
||||
return rows.map((r: { role: Role }) => r.role);
|
||||
}
|
||||
|
||||
export async function groupsOf(db: Db, userId: string): Promise<string[]> {
|
||||
const rows = await db.sql`SELECT group_id FROM group_members WHERE user_id = ${userId} ORDER BY group_id`;
|
||||
return rows.map((r: { group_id: string }) => r.group_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* First user ever to be created becomes the owner. The claim is an INSERT into `learner_meta`
|
||||
* keyed `owner`, so with concurrent sign-ups exactly one wins (primary-key conflict for the rest).
|
||||
*/
|
||||
export async function assignInitialRole(db: Db, userId: string): Promise<Role> {
|
||||
let role: Role = "learner";
|
||||
try {
|
||||
await db.sql`INSERT INTO learner_meta (key, value) VALUES ('owner', ${userId})`;
|
||||
role = "owner";
|
||||
} catch {
|
||||
role = "learner";
|
||||
}
|
||||
await db.sql`INSERT INTO user_roles (user_id, role) VALUES (${userId}, ${role})`;
|
||||
return role;
|
||||
}
|
||||
|
||||
export interface LearnerAuth {
|
||||
handler(req: Request, user: AuthUser | null): Promise<Response>;
|
||||
service: AuthService & { invalidate(userId?: string): void };
|
||||
providers(): ProviderInfo[];
|
||||
hasSso(): Promise<boolean>;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Better Auth's instance type is deeply generic
|
||||
api: any;
|
||||
}
|
||||
|
||||
export function createAuth(ctx: ServerContext, ready: Promise<unknown>, env: Env = process.env): LearnerAuth {
|
||||
const { db, config } = ctx;
|
||||
const groupsClaim = env.STC_AUTH_GROUPS_CLAIM || "groups";
|
||||
const pendingClaims = new Map<string, { groups: string[]; at: number }>();
|
||||
const providers: ProviderInfo[] = [];
|
||||
|
||||
const socialProviders: Record<string, { clientId: string; clientSecret: string }> = {};
|
||||
for (const s of SOCIAL) {
|
||||
const id = env[`STC_AUTH_${s.env}_ID`], secret = env[`STC_AUTH_${s.env}_SECRET`];
|
||||
if (id && secret) {
|
||||
socialProviders[s.id] = { clientId: id, clientSecret: secret };
|
||||
providers.push({ id: s.id, label: s.label, kind: "social" });
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: plugin list is heterogeneous
|
||||
const plugins: any[] = [];
|
||||
if (env.STC_AUTH_OIDC_ISSUER && env.STC_AUTH_OIDC_ID) {
|
||||
const issuer = env.STC_AUTH_OIDC_ISSUER.replace(/\/$/, "");
|
||||
plugins.push(genericOAuth({
|
||||
config: [{
|
||||
providerId: "oidc",
|
||||
clientId: env.STC_AUTH_OIDC_ID,
|
||||
clientSecret: env.STC_AUTH_OIDC_SECRET ?? "",
|
||||
discoveryUrl: env.STC_AUTH_OIDC_DISCOVERY_URL || `${issuer}/.well-known/openid-configuration`,
|
||||
scopes: (env.STC_AUTH_OIDC_SCOPES || "openid email profile").split(/[\s,]+/),
|
||||
pkce: true,
|
||||
mapProfileToUser: (profile) => {
|
||||
const email = typeof profile.email === "string" ? profile.email.toLowerCase() : null;
|
||||
if (email) pendingClaims.set(email, { groups: groupsFromClaims(profile as Record<string, unknown>, groupsClaim), at: Date.now() });
|
||||
return {};
|
||||
},
|
||||
}],
|
||||
}));
|
||||
providers.push({ id: "oidc", label: env.STC_AUTH_OIDC_NAME || "Single sign-on", kind: "oidc" });
|
||||
}
|
||||
plugins.push(sso({
|
||||
provisionUserOnEveryLogin: true,
|
||||
provisionUser: async ({ user, userInfo }) => {
|
||||
await syncClaimGroups(db, user.id, groupsFromClaims(userInfo, groupsClaim));
|
||||
cache.clear();
|
||||
},
|
||||
modelName: "ssoProvider",
|
||||
}));
|
||||
|
||||
const options: BetterAuthOptions = {
|
||||
appName: "STCourses",
|
||||
baseURL: config.publicUrl,
|
||||
basePath: "/api/auth",
|
||||
secret: config.secret,
|
||||
database: bunSqlAdapter(db),
|
||||
emailAndPassword: { enabled: true, minPasswordLength: 8, autoSignIn: true },
|
||||
socialProviders,
|
||||
plugins,
|
||||
trustedOrigins: [config.publicUrl, ...(env.STC_TRUSTED_ORIGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean)],
|
||||
telemetry: { enabled: false },
|
||||
session: { cookieCache: { enabled: true, maxAge: 60 } },
|
||||
databaseHooks: {
|
||||
user: { create: { after: async (user) => { await assignInitialRole(db, user.id); } } },
|
||||
session: {
|
||||
create: {
|
||||
after: async (session) => {
|
||||
const rows = await db.sql`SELECT email FROM auth_user WHERE id = ${session.userId}`;
|
||||
const email = rows[0]?.email?.toLowerCase();
|
||||
const claim = email ? pendingClaims.get(email) : undefined;
|
||||
if (claim && email) {
|
||||
pendingClaims.delete(email);
|
||||
if (Date.now() - claim.at < 10 * 60_000) await syncClaimGroups(db, session.userId, claim.groups);
|
||||
}
|
||||
cache.delete(session.userId);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const auth = betterAuth(options);
|
||||
|
||||
// userId -> roles/groups, briefly cached: every request (media ranges included) resolves the user.
|
||||
const cache = new Map<string, { roles: Role[]; groups: string[]; at: number }>();
|
||||
const TTL = 15_000;
|
||||
|
||||
const service: LearnerAuth["service"] = {
|
||||
async userFromRequest(req) {
|
||||
if (!req.headers.get("cookie")) return null;
|
||||
await ready;
|
||||
let s: { user: { id: string; email: string; name: string } } | null = null;
|
||||
try {
|
||||
s = await auth.api.getSession({ headers: req.headers });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!s) return null;
|
||||
const id = s.user.id;
|
||||
let c = cache.get(id);
|
||||
if (!c || Date.now() - c.at > TTL) {
|
||||
c = { roles: await rolesOf(db, id), groups: await groupsOf(db, id), at: Date.now() };
|
||||
cache.set(id, c);
|
||||
}
|
||||
return { id, email: s.user.email, name: s.user.name, roles: c.roles, groups: c.groups };
|
||||
},
|
||||
invalidate(userId) {
|
||||
if (userId) cache.delete(userId);
|
||||
else cache.clear();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
api: auth.api,
|
||||
service,
|
||||
providers: () => providers,
|
||||
async hasSso() {
|
||||
await ready;
|
||||
const rows = await db.sql`SELECT COUNT(*) AS n FROM auth_sso_provider`;
|
||||
return Number(rows[0].n) > 0;
|
||||
},
|
||||
async handler(req, user) {
|
||||
await ready;
|
||||
const path = new URL(req.url).pathname;
|
||||
if (OWNER_ONLY.test(path) && !user?.roles.includes("owner")) return Response.json({ error: "owner only" }, { status: 403 });
|
||||
return auth.handler(req);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AuthService, Feature, ServerContext } from "@stc/contracts";
|
||||
import { createAuth } from "./auth";
|
||||
import { runMigrations } from "./migrate";
|
||||
import { json } from "./http";
|
||||
|
||||
/**
|
||||
* The learner feature: auth, library, reader, notes, progress and exercises.
|
||||
* Returns the routes plus the `AuthService` that replaces the server's anonymous one.
|
||||
*/
|
||||
export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthService } {
|
||||
const ready = runMigrations(ctx.db);
|
||||
ready.catch((e) => console.error("[learner] migrations failed", e));
|
||||
const auth = createAuth(ctx, ready);
|
||||
const routes: Feature["routes"] = {
|
||||
"/api/auth/*": {
|
||||
GET: (req, rc) => auth.handler(req, rc.user),
|
||||
POST: (req, rc) => auth.handler(req, rc.user),
|
||||
},
|
||||
"/api/me": { GET: async (_req, rc) => json({ user: rc.user }) },
|
||||
"/api/learner/providers": {
|
||||
GET: async () => json({ emailPassword: true, providers: auth.providers(), sso: await auth.hasSso() }),
|
||||
},
|
||||
};
|
||||
return { name: "learner", routes, auth: auth.service };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AuthUser, RequestContext, RouteHandler } from "@stc/contracts";
|
||||
|
||||
export const json = (body: unknown, status = 200): Response => Response.json(body, { status });
|
||||
export const notFound = (): Response => json({ error: "not found" }, 404);
|
||||
export const badRequest = (message: string): Response => json({ error: message }, 400);
|
||||
|
||||
/** Wraps a handler that needs a signed-in user; anonymous callers get 401. */
|
||||
export const signedIn = (h: (req: Request, rc: RequestContext, user: AuthUser) => Response | Promise<Response>): RouteHandler =>
|
||||
(req, rc) => (rc.user ? h(req, rc, rc.user) : json({ error: "sign in required" }, 401));
|
||||
|
||||
/** Owner-only handler: 401 when anonymous, 403 for other roles. */
|
||||
export const ownerOnly = (h: (req: Request, rc: RequestContext, user: AuthUser) => Response | Promise<Response>): RouteHandler =>
|
||||
signedIn((req, rc, user) => (user.roles.includes("owner") ? h(req, rc, user) : json({ error: "owner only" }, 403)));
|
||||
|
||||
export async function body<T = Record<string, unknown>>(req: Request): Promise<T | null> {
|
||||
try {
|
||||
const v = await req.json();
|
||||
return v && typeof v === "object" ? (v as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Route params, decoded (core's matcher already decodes; Bun's router may not). */
|
||||
export const param = (rc: RequestContext, name: string): string => {
|
||||
const v = rc.params[name] ?? "";
|
||||
try { return decodeURIComponent(v); } catch { return v; }
|
||||
};
|
||||
Reference in New Issue
Block a user