@viktorbarzin/auth 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/schema.js ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The identity tables an app adds to its own schema
3
+ * (`export * from "@viktorbarzin/auth/schema"` in the app's Drizzle schema).
4
+ *
5
+ * - auth_user, auth_session, auth_account, auth_verification: Better Auth's
6
+ * tables (Drizzle adapter). Property names are the field names Better Auth
7
+ * uses; columns are snake_case.
8
+ * - member_checks: the last membership answer per OIDC `sub`, re-asked every
9
+ * 15 minutes.
10
+ * - access_requests: request + approve sign-up.
11
+ *
12
+ * Changing a column here is a breaking change: apps regenerate migrations.
13
+ */
14
+ import { boolean, index, pgTable, text, timestamp, uniqueIndex, varchar, } from "drizzle-orm/pg-core";
15
+ const tz = { withTimezone: true, mode: "date" };
16
+ export const authUser = pgTable("auth_user", {
17
+ id: text("id").primaryKey(),
18
+ name: text("name").notNull(),
19
+ email: text("email").notNull().unique(),
20
+ emailVerified: boolean("email_verified").default(false).notNull(),
21
+ image: text("image"),
22
+ createdAt: timestamp("created_at", tz).notNull(),
23
+ updatedAt: timestamp("updated_at", tz)
24
+ .$onUpdate(() => new Date())
25
+ .notNull(),
26
+ });
27
+ export const authSession = pgTable("auth_session", {
28
+ id: text("id").primaryKey(),
29
+ expiresAt: timestamp("expires_at", tz).notNull(),
30
+ token: text("token").notNull().unique(),
31
+ createdAt: timestamp("created_at", tz).notNull(),
32
+ updatedAt: timestamp("updated_at", tz)
33
+ .$onUpdate(() => new Date())
34
+ .notNull(),
35
+ ipAddress: text("ip_address"),
36
+ userAgent: text("user_agent"),
37
+ userId: text("user_id")
38
+ .notNull()
39
+ .references(() => authUser.id, { onDelete: "cascade" }),
40
+ }, (table) => [index("auth_session_user_id_idx").on(table.userId)]);
41
+ export const authAccount = pgTable("auth_account", {
42
+ id: text("id").primaryKey(),
43
+ /** The provider's subject: the OIDC `sub`. */
44
+ accountId: text("account_id").notNull(),
45
+ providerId: text("provider_id").notNull(),
46
+ userId: text("user_id")
47
+ .notNull()
48
+ .references(() => authUser.id, { onDelete: "cascade" }),
49
+ accessToken: text("access_token"),
50
+ refreshToken: text("refresh_token"),
51
+ idToken: text("id_token"),
52
+ accessTokenExpiresAt: timestamp("access_token_expires_at", tz),
53
+ refreshTokenExpiresAt: timestamp("refresh_token_expires_at", tz),
54
+ scope: text("scope"),
55
+ password: text("password"),
56
+ createdAt: timestamp("created_at", tz).notNull(),
57
+ updatedAt: timestamp("updated_at", tz)
58
+ .$onUpdate(() => new Date())
59
+ .notNull(),
60
+ }, (table) => [
61
+ index("auth_account_user_id_idx").on(table.userId),
62
+ uniqueIndex("auth_account_provider_subject_key").on(table.providerId, table.accountId),
63
+ ]);
64
+ export const authVerification = pgTable("auth_verification", {
65
+ id: text("id").primaryKey(),
66
+ identifier: text("identifier").notNull(),
67
+ value: text("value").notNull(),
68
+ expiresAt: timestamp("expires_at", tz).notNull(),
69
+ createdAt: timestamp("created_at", tz).notNull(),
70
+ updatedAt: timestamp("updated_at", tz)
71
+ .$onUpdate(() => new Date())
72
+ .notNull(),
73
+ }, (table) => [index("auth_verification_identifier_idx").on(table.identifier)]);
74
+ /** The last membership answer for a subject, from the id_token, a bearer token or the provider's API. */
75
+ export const memberChecks = pgTable("member_checks", {
76
+ sub: varchar("sub", { length: 255 }).primaryKey(),
77
+ username: varchar("username", { length: 255 }).notNull(),
78
+ member: boolean("member").notNull(),
79
+ admin: boolean("admin").notNull(),
80
+ /** "id_token", "token" or "api". */
81
+ source: varchar("source", { length: 16 }).notNull(),
82
+ checkedAt: timestamp("checked_at", tz).notNull(),
83
+ });
84
+ /** A signed-in person who is not in the app's users group yet. */
85
+ export const accessRequests = pgTable("access_requests", {
86
+ sub: varchar("sub", { length: 255 }).primaryKey(),
87
+ username: varchar("username", { length: 255 }).notNull(),
88
+ email: varchar("email", { length: 320 }),
89
+ name: varchar("name", { length: 255 }),
90
+ /** "pending" or "approved". */
91
+ status: varchar("status", { length: 16 }).notNull(),
92
+ requestedAt: timestamp("requested_at", tz).defaultNow().notNull(),
93
+ decidedBy: varchar("decided_by", { length: 255 }),
94
+ decidedAt: timestamp("decided_at", tz),
95
+ });
96
+ export const authSchema = {
97
+ authUser,
98
+ authSession,
99
+ authAccount,
100
+ authVerification,
101
+ memberChecks,
102
+ accessRequests,
103
+ };
104
+ /** The Better Auth Drizzle adapter's schema map, keyed by model name. */
105
+ export const betterAuthModels = {
106
+ auth_user: authUser,
107
+ auth_session: authSession,
108
+ auth_account: authAccount,
109
+ auth_verification: authVerification,
110
+ };
@@ -0,0 +1,50 @@
1
+ export declare function freePort(): Promise<number>;
2
+ export interface BuiltServer {
3
+ origin: string;
4
+ /** The METRICS_PORT listener (/metrics), separate from the app port; null when not started. */
5
+ metricsOrigin: string | null;
6
+ /** Every stdout and stderr line so far. */
7
+ logs: string[];
8
+ /** Send a signal to the server process. */
9
+ kill(signal: NodeJS.Signals): void;
10
+ /** Resolves with the exit code once the process has exited. */
11
+ exited: Promise<number>;
12
+ stop(): Promise<void>;
13
+ }
14
+ /** Env keys that must not leak from the developer's shell into the server under test. */
15
+ export declare const SCRUBBED_ENV: string[];
16
+ export interface StartBuiltServerOptions {
17
+ /** The built entry, e.g. `<app>/.output/server/index.mjs`. */
18
+ entry: string;
19
+ env: Record<string, string>;
20
+ /** Working directory; defaults to two levels above the entry (the app root). */
21
+ cwd?: string;
22
+ /** Start the METRICS_PORT listener too. Default true. */
23
+ metricsPort?: boolean;
24
+ /** Extra env computed from the chosen port, e.g. PUBLIC_ORIGINS. */
25
+ origin?: (port: number) => Record<string, string>;
26
+ /** How long to wait for /healthz. Default 15 s. */
27
+ startTimeoutMs?: number;
28
+ }
29
+ export declare function startBuiltServer(opts: StartBuiltServerOptions): Promise<BuiltServer>;
30
+ /** A minimal cookie jar for one origin. */
31
+ export declare class CookieJar {
32
+ readonly cookies: Map<string, string>;
33
+ /** Every Set-Cookie header seen, attributes included. */
34
+ readonly raw: string[];
35
+ store(response: Response): void;
36
+ header(): string;
37
+ }
38
+ export interface Hop {
39
+ url: string;
40
+ status: number;
41
+ location: string | null;
42
+ }
43
+ /**
44
+ * Follow redirects by hand like a browser would, keeping cookies for
45
+ * `appOrigin`. Returns every hop and the final response.
46
+ */
47
+ export declare function follow(start: string, jar: CookieJar, appOrigin: string, maxHops?: number): Promise<{
48
+ hops: Hop[];
49
+ response: Response;
50
+ }>;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Helpers for HTTP tests against an app's built server: start it on a free
3
+ * 127.0.0.1 port (plus a METRICS_PORT listener), a cookie jar, and a redirect
4
+ * follower that behaves like a browser.
5
+ */
6
+ import { dirname, resolve } from "node:path";
7
+ export async function freePort() {
8
+ const probe = Bun.serve({
9
+ hostname: "127.0.0.1",
10
+ port: 0,
11
+ fetch: () => new Response(),
12
+ });
13
+ const port = probe.port;
14
+ await probe.stop(true);
15
+ return port;
16
+ }
17
+ /** Env keys that must not leak from the developer's shell into the server under test. */
18
+ export const SCRUBBED_ENV = [
19
+ "DEV_USER",
20
+ "PUBLIC_ORIGINS",
21
+ "OIDC_DISCOVERY_URL",
22
+ "OIDC_CLIENT_ID",
23
+ "OIDC_CLIENT_SECRET",
24
+ "OIDC_CLIENT_SECRET_FILE",
25
+ "BETTER_AUTH_SECRET",
26
+ "BETTER_AUTH_SECRET_FILE",
27
+ "API_TOKEN_AUDIENCES",
28
+ "AUTHENTIK_API_URL",
29
+ "AUTHENTIK_READER_TOKEN",
30
+ "AUTHENTIK_READER_TOKEN_FILE",
31
+ "AUTHENTIK_APPROVER_TOKEN",
32
+ "AUTHENTIK_APPROVER_TOKEN_FILE",
33
+ "AUTHENTIK_USERS_GROUP_ID",
34
+ "DATABASE_URL",
35
+ "DATABASE_PASSWORD_FILE",
36
+ "METRICS_PORT",
37
+ ];
38
+ export async function startBuiltServer(opts) {
39
+ const port = await freePort();
40
+ const metricsPort = opts.metricsPort === false ? null : await freePort();
41
+ const origin = `http://127.0.0.1:${port}`;
42
+ const base = { ...process.env };
43
+ for (const key of SCRUBBED_ENV)
44
+ delete base[key];
45
+ const proc = Bun.spawn([process.execPath, opts.entry], {
46
+ cwd: opts.cwd ?? resolve(dirname(opts.entry), "../.."),
47
+ env: {
48
+ ...base,
49
+ NODE_ENV: "test",
50
+ LOG_LEVEL: "INFO",
51
+ HOST: "127.0.0.1",
52
+ PORT: String(port),
53
+ ...(metricsPort === null ? {} : { METRICS_PORT: String(metricsPort) }),
54
+ ...(opts.origin?.(port) ?? {}),
55
+ ...opts.env,
56
+ },
57
+ stdout: "pipe",
58
+ stderr: "pipe",
59
+ });
60
+ const logs = [];
61
+ const pump = async (stream) => {
62
+ const decoder = new TextDecoder();
63
+ for await (const chunk of stream)
64
+ logs.push(...decoder.decode(chunk).split("\n").filter(Boolean));
65
+ };
66
+ void pump(proc.stdout);
67
+ void pump(proc.stderr);
68
+ const deadline = Date.now() + (opts.startTimeoutMs ?? 15_000);
69
+ for (;;) {
70
+ try {
71
+ const res = await fetch(`${origin}/healthz`);
72
+ if (res.ok)
73
+ break;
74
+ }
75
+ catch {
76
+ // not listening yet
77
+ }
78
+ if (proc.exitCode !== null || Date.now() > deadline) {
79
+ proc.kill(9);
80
+ throw new Error(`server did not start:\n${logs.join("\n")}`);
81
+ }
82
+ await Bun.sleep(100);
83
+ }
84
+ return {
85
+ origin,
86
+ metricsOrigin: metricsPort === null ? null : `http://127.0.0.1:${metricsPort}`,
87
+ logs,
88
+ kill: (signal) => proc.kill(signal),
89
+ exited: proc.exited,
90
+ async stop() {
91
+ proc.kill();
92
+ // Graceful shutdown waits for open streams; do not wait long.
93
+ const exited = await Promise.race([
94
+ proc.exited.then(() => true),
95
+ Bun.sleep(2_000).then(() => false),
96
+ ]);
97
+ if (!exited) {
98
+ proc.kill(9);
99
+ await proc.exited;
100
+ }
101
+ },
102
+ };
103
+ }
104
+ /** A minimal cookie jar for one origin. */
105
+ export class CookieJar {
106
+ cookies = new Map();
107
+ /** Every Set-Cookie header seen, attributes included. */
108
+ raw = [];
109
+ store(response) {
110
+ for (const header of response.headers.getSetCookie()) {
111
+ this.raw.push(header);
112
+ const [pair, ...attributes] = header.split(";");
113
+ const eq = (pair ?? "").indexOf("=");
114
+ const name = (pair ?? "").slice(0, eq).trim();
115
+ const value = (pair ?? "").slice(eq + 1).trim();
116
+ const expired = attributes.some((a) => {
117
+ const [k, v] = a.trim().split("=");
118
+ if (k?.toLowerCase() === "max-age")
119
+ return Number(v) <= 0;
120
+ if (k?.toLowerCase() === "expires")
121
+ return new Date(v ?? "").getTime() < Date.now();
122
+ return false;
123
+ });
124
+ if (expired || value === "")
125
+ this.cookies.delete(name);
126
+ else
127
+ this.cookies.set(name, value);
128
+ }
129
+ }
130
+ header() {
131
+ return [...this.cookies].map(([k, v]) => `${k}=${v}`).join("; ");
132
+ }
133
+ }
134
+ /**
135
+ * Follow redirects by hand like a browser would, keeping cookies for
136
+ * `appOrigin`. Returns every hop and the final response.
137
+ */
138
+ export async function follow(start, jar, appOrigin, maxHops = 12) {
139
+ const hops = [];
140
+ let url = start;
141
+ for (let i = 0; i < maxHops; i++) {
142
+ const target = new URL(url);
143
+ const headers = {};
144
+ if (target.origin === appOrigin && jar.cookies.size > 0)
145
+ headers.cookie = jar.header();
146
+ const response = await fetch(url, { redirect: "manual", headers });
147
+ if (target.origin === appOrigin)
148
+ jar.store(response);
149
+ const location = response.headers.get("location");
150
+ hops.push({ url, status: response.status, location });
151
+ if (response.status < 300 || response.status >= 400 || !location) {
152
+ return { hops, response };
153
+ }
154
+ url = new URL(location, url).toString();
155
+ }
156
+ throw new Error(`too many redirects: ${hops.map((h) => h.url).join(" -> ")}`);
157
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Test helpers for apps using @viktorbarzin/auth: a mock Authentik (OIDC
3
+ * provider plus the /api/v3 calls the package makes) and helpers for HTTP
4
+ * tests against a built server. Never import this from app code.
5
+ */
6
+ export { type BuiltServer, CookieJar, follow, freePort, type Hop, SCRUBBED_ENV, type StartBuiltServerOptions, startBuiltServer, } from "./built-server.js";
7
+ export { type AuthorizeRecord, type MockAuthentik, type MockAuthentikOptions, type MockUser, startMockAuthentik, } from "./mock-authentik.js";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Test helpers for apps using @viktorbarzin/auth: a mock Authentik (OIDC
3
+ * provider plus the /api/v3 calls the package makes) and helpers for HTTP
4
+ * tests against a built server. Never import this from app code.
5
+ */
6
+ export { CookieJar, follow, freePort, SCRUBBED_ENV, startBuiltServer, } from "./built-server.js";
7
+ export { startMockAuthentik, } from "./mock-authentik.js";
@@ -0,0 +1,70 @@
1
+ import { type JWTPayload } from "jose";
2
+ export interface MockUser {
3
+ pk: number;
4
+ uid: string;
5
+ username: string;
6
+ email: string | null;
7
+ name: string;
8
+ isActive: boolean;
9
+ groups: string[];
10
+ }
11
+ export interface AuthorizeRecord {
12
+ clientId: string;
13
+ scope: string;
14
+ prompt: string | null;
15
+ codeChallengeMethod: string | null;
16
+ nonce: string | null;
17
+ user: string;
18
+ }
19
+ export interface MockAuthentik {
20
+ /** The provider's issuer, e.g. http://127.0.0.1:1234/application/o/<slug>/ */
21
+ issuer: string;
22
+ discoveryUrl: string;
23
+ apiUrl: string;
24
+ clientId: string;
25
+ clientSecret: string;
26
+ readerToken: string;
27
+ approverToken: string;
28
+ usersGroupPk: string;
29
+ /** The users group's name: the group lookup answers it and add_user adds it. */
30
+ usersGroup: string;
31
+ users: Map<string, MockUser>;
32
+ /** Who the browser is signed in as at the provider: a username, or null (no session). */
33
+ session: string | null;
34
+ /** Who logs in when the provider shows its login form (no session, or prompt=login). */
35
+ loginAs: string | null;
36
+ authorizations: AuthorizeRecord[];
37
+ tokenRequests: Array<{
38
+ grantType: string;
39
+ scope: string;
40
+ }>;
41
+ apiCalls: Array<{
42
+ method: string;
43
+ path: string;
44
+ token: string | null;
45
+ }>;
46
+ /** Fail every /api/v3 call with this status (simulate an outage). */
47
+ apiFailure: number | null;
48
+ addUser(user: Partial<MockUser> & {
49
+ username: string;
50
+ }): MockUser;
51
+ /** Sign an access token for bearer tests. */
52
+ mintAccessToken(claims: JWTPayload, options?: {
53
+ alg?: string;
54
+ expiresIn?: string | number;
55
+ audience?: string;
56
+ }): Promise<string>;
57
+ stop(): Promise<void>;
58
+ }
59
+ export interface MockAuthentikOptions {
60
+ clientId: string;
61
+ clientSecret?: string;
62
+ /** The application slug in the issuer path. Defaults to clientId. */
63
+ slug?: string;
64
+ /** Defaults to "App Users". */
65
+ usersGroup?: string;
66
+ users?: Array<Partial<MockUser> & {
67
+ username: string;
68
+ }>;
69
+ }
70
+ export declare function startMockAuthentik(options: MockAuthentikOptions): Promise<MockAuthentik>;