@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.
@@ -0,0 +1,588 @@
1
+ /**
2
+ * App-side sign-in against Authentik (ADR-0001, Identity and access):
3
+ * Better Auth's generic OAuth with PKCE and a verified id_token, sessions in
4
+ * the app's database (Drizzle adapter), host-only `__Host-` cookies, exact
5
+ * trustedOrigins, sessions capped at 7 days and never extended, and no
6
+ * `offline_access`. Plus bearer tokens for non-browser clients, the 15-minute
7
+ * membership re-check, request + approve, and the CSRF origin check.
8
+ *
9
+ * Sign-in goes through `<basePath>/start` and `<basePath>/finish`, not Better
10
+ * Auth's own /sign-in/social over HTTP. Everything here takes a Request and
11
+ * returns a Response or data; the app wires it into its framework's routes
12
+ * and middleware.
13
+ */
14
+ import { getLogger } from "@viktorbarzin/log";
15
+ import { createDiscoveredVerifier, } from "@viktorbarzin/oidc-token";
16
+ import { ApiError, createErrorResponder, errorResponse, } from "@viktorbarzin/runtime";
17
+ import { betterAuth } from "better-auth";
18
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
19
+ import { APIError, getOAuthState } from "better-auth/api";
20
+ import { genericOAuth, } from "better-auth/plugins/generic-oauth";
21
+ import { and, asc, eq } from "drizzle-orm";
22
+ import { AuthentikUnavailable, createAuthentikClient } from "./authentik.js";
23
+ import { DEFAULTS, } from "./config.js";
24
+ import { isSameOriginRequest } from "./csrf.js";
25
+ import { createMembershipChecker, fileAccessRequest, getAccessRequest, readMemberCheck, recordClaimedGroups, saveMemberCheck, } from "./membership.js";
26
+ import { authMetrics } from "./metrics.js";
27
+ import { LOGIN_PARAM, pathWithReturn, safeReturnPath, signInPaths, } from "./return-path.js";
28
+ import { accessRequests, authAccount, betterAuthModels } from "./schema.js";
29
+ /** Better Auth endpoints an app does not use; refused over HTTP (404). */
30
+ export const DISABLED_PATHS = [
31
+ "/sign-in/social",
32
+ "/sign-in/oauth2",
33
+ "/sign-in/email",
34
+ "/sign-up/email",
35
+ "/update-user",
36
+ "/delete-user",
37
+ "/change-email",
38
+ "/change-password",
39
+ "/set-password",
40
+ "/request-password-reset",
41
+ "/reset-password",
42
+ "/link-social",
43
+ "/unlink-account",
44
+ "/list-accounts",
45
+ "/account-info",
46
+ "/get-access-token",
47
+ "/refresh-token",
48
+ "/update-session",
49
+ ];
50
+ /** How often a missing provider (discovery failed at init) may trigger a rebuild. */
51
+ const REBUILD_INTERVAL_MS = 10_000;
52
+ /** The config with every default filled in. */
53
+ function resolve(config) {
54
+ const publicOrigins = config.publicOrigins.map((origin) => new URL(origin).origin);
55
+ return {
56
+ ...config,
57
+ publicOrigins,
58
+ providerId: config.providerId ?? DEFAULTS.providerId,
59
+ basePath: config.basePath ?? DEFAULTS.basePath,
60
+ scopes: config.scopes ?? [...DEFAULTS.scopes],
61
+ sessionMaxAgeSeconds: config.sessionMaxAgeSeconds ?? DEFAULTS.sessionMaxAgeSeconds,
62
+ cookiePrefix: config.cookiePrefix ?? `__Host-${config.appSlug}`,
63
+ clientIpHeader: config.clientIpHeader ?? DEFAULTS.clientIpHeader,
64
+ apiTokenAudiences: config.apiTokenAudiences ?? (config.oidc ? [config.oidc.clientId] : []),
65
+ guestUsername: config.guestUsername ?? DEFAULTS.guestUsername,
66
+ membershipMaxAgeMs: config.membershipMaxAgeMs ?? DEFAULTS.membershipMaxAgeMs,
67
+ adminRecheckMaxAgeMs: config.adminRecheckMaxAgeMs ?? DEFAULTS.adminRecheckMaxAgeMs,
68
+ // The dev user stands in only while sign-in is off. With OIDC configured,
69
+ // a stray DEV_USER would otherwise make every anonymous caller an admin.
70
+ devUser: config.oidc ? null : (config.devUser ?? null),
71
+ streamPaths: config.streamPaths ?? [...DEFAULTS.streamPaths],
72
+ loggerPrefix: config.loggerPrefix ?? config.appSlug,
73
+ };
74
+ }
75
+ function buildBetterAuth(cfg, oidc, secret, onVerifiedProfile) {
76
+ const baseURL = cfg.publicOrigins[0];
77
+ if (!baseURL)
78
+ throw new Error("publicOrigins must be set when OIDC sign-in is enabled");
79
+ return betterAuth({
80
+ appName: cfg.appName,
81
+ baseURL,
82
+ basePath: cfg.basePath,
83
+ secret,
84
+ trustedOrigins: cfg.publicOrigins,
85
+ database: drizzleAdapter(cfg.db(), {
86
+ provider: "pg",
87
+ schema: betterAuthModels,
88
+ }),
89
+ user: { modelName: "auth_user" },
90
+ session: {
91
+ modelName: "auth_session",
92
+ expiresIn: cfg.sessionMaxAgeSeconds,
93
+ // An absolute cap: the expiry is never pushed out.
94
+ disableSessionRefresh: true,
95
+ cookieCache: { enabled: false },
96
+ },
97
+ account: {
98
+ modelName: "auth_account",
99
+ // Never link accounts by email: users are keyed on sub.
100
+ accountLinking: { enabled: false },
101
+ encryptOAuthTokens: true,
102
+ },
103
+ verification: { modelName: "auth_verification" },
104
+ emailAndPassword: { enabled: false },
105
+ disabledPaths: [...DISABLED_PATHS],
106
+ telemetry: { enabled: false },
107
+ advanced: {
108
+ // The rate limiter (on in production) keys on the client IP. Behind a
109
+ // CDN and Traefik, X-Forwarded-For carries several hops; the ingress's
110
+ // real-ip plugin overwrites this header with the TCP peer's address,
111
+ // so a client cannot spoof it.
112
+ ipAddress: { ipAddressHeaders: [cfg.clientIpHeader] },
113
+ // `__Host-` needs Secure, Path=/ and no Domain; Better Auth would add
114
+ // its own `__Secure-` prefix with useSecureCookies.
115
+ cookiePrefix: cfg.cookiePrefix,
116
+ useSecureCookies: false,
117
+ defaultCookieAttributes: {
118
+ secure: true,
119
+ sameSite: "lax",
120
+ path: "/",
121
+ httpOnly: true,
122
+ },
123
+ },
124
+ plugins: [
125
+ genericOAuth({
126
+ config: [
127
+ {
128
+ providerId: cfg.providerId,
129
+ name: cfg.providerId,
130
+ discoveryUrl: oidc.discoveryUrl,
131
+ requireIdTokenVerification: true,
132
+ clientId: oidc.clientId,
133
+ clientSecret: oidc.clientSecret,
134
+ // No offline_access: web sign-in gets no refresh token.
135
+ scopes: cfg.scopes,
136
+ pkce: true,
137
+ disableProviderLogout: true,
138
+ overrideUserInfo: true,
139
+ mapProfileToUser: onVerifiedProfile,
140
+ },
141
+ ],
142
+ }),
143
+ ],
144
+ });
145
+ }
146
+ function redirect(location, extra) {
147
+ const headers = new Headers(extra);
148
+ headers.set("location", location);
149
+ headers.set("cache-control", "no-store");
150
+ return new Response(null, { status: 302, headers });
151
+ }
152
+ /** Copy every Set-Cookie from Better Auth's headers onto a response. */
153
+ function withCookies(response, from) {
154
+ for (const cookie of from.getSetCookie())
155
+ response.headers.append("set-cookie", cookie);
156
+ return response;
157
+ }
158
+ function authKey(cfg) {
159
+ return JSON.stringify([
160
+ cfg.appName,
161
+ cfg.publicOrigins,
162
+ cfg.betterAuthSecret,
163
+ cfg.oidc,
164
+ cfg.providerId,
165
+ cfg.basePath,
166
+ cfg.scopes,
167
+ cfg.sessionMaxAgeSeconds,
168
+ cfg.cookiePrefix,
169
+ cfg.clientIpHeader,
170
+ ]);
171
+ }
172
+ /**
173
+ * The app's identity layer. `config` is re-read on every call (cheap; the app
174
+ * builds it from the environment); the Better Auth instance is rebuilt only
175
+ * when its settings change.
176
+ */
177
+ export function createAppAuth(config) {
178
+ const current = () => resolve(config());
179
+ let devUserIgnoredLogged = false;
180
+ const loggers = new Map();
181
+ const logger = (part) => {
182
+ const cfg = config();
183
+ if (cfg.logger)
184
+ return cfg.logger;
185
+ const name = `${cfg.loggerPrefix ?? cfg.appSlug}.${part}`;
186
+ let found = loggers.get(name);
187
+ if (!found) {
188
+ found = getLogger(name);
189
+ loggers.set(name, found);
190
+ }
191
+ return found;
192
+ };
193
+ const metrics = () => {
194
+ const m = config().metrics;
195
+ return authMetrics({
196
+ ...(m?.registry ? { registry: m.registry } : {}),
197
+ prefix: m?.prefix ?? DEFAULTS.metricsPrefix,
198
+ });
199
+ };
200
+ const authentik = () => {
201
+ const cfg = config();
202
+ return createAuthentikClient({
203
+ ...cfg.authentik,
204
+ usersGroup: cfg.groups.users,
205
+ logger: logger("authentik"),
206
+ });
207
+ };
208
+ const membership = createMembershipChecker({
209
+ authentik,
210
+ groups: () => config().groups,
211
+ defaultMaxAgeMs: () => current().membershipMaxAgeMs,
212
+ metrics,
213
+ log: logger("membership"),
214
+ });
215
+ let instance = null;
216
+ let lastRebuild = 0;
217
+ let verifier = null;
218
+ async function enrich(db, sub, username) {
219
+ const hook = config().enrich;
220
+ return hook ? hook(db, { sub, username }) : {};
221
+ }
222
+ /** The anonymous guest came back: retry with prompt=login once, then stop on the error page. */
223
+ async function redirectGuest(cfg) {
224
+ metrics().signIns.inc({ outcome: "guest" });
225
+ const paths = signInPaths(cfg.basePath);
226
+ const state = await getOAuthState().catch(() => null);
227
+ const callback = new URL(state?.callbackURL ?? paths.finish, "http://app.invalid");
228
+ const returnTo = callback.searchParams.get("return") ?? "/";
229
+ const retried = callback.searchParams.has(LOGIN_PARAM);
230
+ const location = retried
231
+ ? `${paths.error}?error=guest_session`
232
+ : pathWithReturn(paths.start, returnTo, true);
233
+ logger("auth").info("guest session at the identity provider; asking for a real login", { retried });
234
+ throw new APIError("FOUND", undefined, { location });
235
+ }
236
+ /**
237
+ * Runs on every sign-in, after Better Auth verified the id_token (issuer,
238
+ * audience, signature, nonce): records the membership answer the token
239
+ * carries and, for a non-member in request mode, an access request.
240
+ */
241
+ async function onVerifiedProfile(profile) {
242
+ const cfg = current();
243
+ const sub = typeof profile.sub === "string" ? profile.sub : String(profile.sub ?? "");
244
+ const username = typeof profile.preferred_username === "string"
245
+ ? profile.preferred_username
246
+ : "";
247
+ if (!sub || !username) {
248
+ throw new APIError("UNAUTHORIZED", {
249
+ message: "the identity provider sent no sub or username",
250
+ });
251
+ }
252
+ if (username === cfg.guestUsername)
253
+ await redirectGuest(cfg);
254
+ const email = typeof profile.email === "string" && profile.email !== ""
255
+ ? profile.email
256
+ : null;
257
+ const name = typeof profile.name === "string" && profile.name !== ""
258
+ ? profile.name
259
+ : username;
260
+ const db = cfg.db();
261
+ await recordClaimedGroups(db, sub, username, profile.groups, "id_token", cfg.groups);
262
+ await enrich(db, sub, username);
263
+ const answer = await membership.current(db, sub, username);
264
+ if (!answer.member && cfg.signup === "request")
265
+ await fileAccessRequest(db, { sub, username, email, name });
266
+ metrics().signIns.inc({ outcome: answer.member ? "member" : "pending" });
267
+ logger("auth").info("signed in", {
268
+ sub,
269
+ user: username,
270
+ member: answer.member,
271
+ });
272
+ return {
273
+ name,
274
+ // Better Auth needs an email; it is never used to authorize (users are keyed on sub).
275
+ email: email ?? `${sub}@users.${cfg.appSlug}.invalid`,
276
+ emailVerified: profile.email_verified === true,
277
+ image: undefined,
278
+ };
279
+ }
280
+ function getAuth() {
281
+ const cfg = current();
282
+ if (!cfg.oidc || !cfg.betterAuthSecret)
283
+ return null;
284
+ const key = authKey(cfg);
285
+ if (instance?.key !== key)
286
+ instance = {
287
+ auth: buildBetterAuth(cfg, cfg.oidc, cfg.betterAuthSecret, onVerifiedProfile),
288
+ key,
289
+ };
290
+ return instance.auth;
291
+ }
292
+ /**
293
+ * The instance with the provider registered. Better Auth skips a provider
294
+ * whose discovery fetch failed at init; rebuild (at most every 10 s) so a
295
+ * provider blip at pod start does not disable sign-in until a restart.
296
+ */
297
+ async function getAuthWithProvider() {
298
+ const auth = getAuth();
299
+ if (!auth)
300
+ return null;
301
+ const providerId = current().providerId;
302
+ const registered = async (a) => (await a.$context).socialProviders.some((provider) => provider.id === providerId);
303
+ if (await registered(auth))
304
+ return auth;
305
+ const now = Date.now();
306
+ if (now - lastRebuild >= REBUILD_INTERVAL_MS) {
307
+ lastRebuild = now;
308
+ instance = null;
309
+ const rebuilt = getAuth();
310
+ if (rebuilt && (await registered(rebuilt)))
311
+ return rebuilt;
312
+ }
313
+ logger("auth").warning("the identity provider is not registered; discovery failed");
314
+ return null;
315
+ }
316
+ function tokenVerifier() {
317
+ const cfg = current();
318
+ if (!cfg.oidc)
319
+ return null;
320
+ const key = JSON.stringify([cfg.oidc.discoveryUrl, cfg.apiTokenAudiences]);
321
+ if (verifier?.key !== key) {
322
+ verifier = {
323
+ key,
324
+ verifier: createDiscoveredVerifier({
325
+ discoveryUrl: cfg.oidc.discoveryUrl,
326
+ audiences: cfg.apiTokenAudiences,
327
+ onOutcome: (outcome) => metrics().tokenVerifications.inc({ outcome }),
328
+ logger: logger("tokens"),
329
+ }),
330
+ };
331
+ }
332
+ return verifier.verifier;
333
+ }
334
+ async function userFromToken(token) {
335
+ const cfg = current();
336
+ if (token.preferredUsername === cfg.guestUsername)
337
+ return null;
338
+ const db = cfg.db();
339
+ if (!(await readMemberCheck(db, token.sub)))
340
+ await recordClaimedGroups(db, token.sub, token.preferredUsername, token.groups, "token", cfg.groups);
341
+ const answer = await membership.current(db, token.sub, token.preferredUsername);
342
+ const extra = await enrich(db, token.sub, token.preferredUsername);
343
+ return {
344
+ ...extra,
345
+ sub: token.sub,
346
+ username: token.preferredUsername,
347
+ via: "token",
348
+ ...answer,
349
+ };
350
+ }
351
+ async function sessionUser(request) {
352
+ if (!request.headers.get("cookie"))
353
+ return null;
354
+ const auth = getAuth();
355
+ if (!auth)
356
+ return null;
357
+ const found = await auth.api.getSession({
358
+ headers: request.headers,
359
+ query: { disableCookieCache: true, disableRefresh: true },
360
+ });
361
+ if (!found)
362
+ return null;
363
+ const cfg = current();
364
+ const db = cfg.db();
365
+ const [account] = await db
366
+ .select({ sub: authAccount.accountId })
367
+ .from(authAccount)
368
+ .where(and(eq(authAccount.userId, found.user.id), eq(authAccount.providerId, cfg.providerId)))
369
+ .limit(1);
370
+ if (!account)
371
+ return null;
372
+ const sub = account.sub;
373
+ // The username the verified id_token carried at the last sign-in.
374
+ const username = (await readMemberCheck(db, sub))?.username ?? found.user.name;
375
+ if (username === cfg.guestUsername)
376
+ return null;
377
+ const answer = await membership.current(db, sub, username);
378
+ const extra = await enrich(db, sub, username);
379
+ return { ...extra, sub, username, via: "session", ...answer };
380
+ }
381
+ async function resolveUser(request) {
382
+ if (request.headers.has("authorization")) {
383
+ const check = tokenVerifier();
384
+ if (!check) {
385
+ metrics().tokenVerifications.inc({ outcome: "not_configured" });
386
+ return null;
387
+ }
388
+ const token = await check.verifyRequest(request);
389
+ return token ? userFromToken(token) : null;
390
+ }
391
+ const fromSession = await sessionUser(request);
392
+ if (fromSession)
393
+ return fromSession;
394
+ const cfg = current();
395
+ if (!devUserIgnoredLogged && cfg.oidc && config().devUser) {
396
+ devUserIgnoredLogged = true;
397
+ logger("auth").error("devUser is ignored because OIDC sign-in is configured");
398
+ }
399
+ if (cfg.devUser) {
400
+ const extra = await enrich(cfg.db(), null, cfg.devUser);
401
+ return {
402
+ ...extra,
403
+ sub: null,
404
+ username: cfg.devUser,
405
+ via: "dev",
406
+ member: true,
407
+ admin: true,
408
+ };
409
+ }
410
+ return null;
411
+ }
412
+ async function requireUser(request) {
413
+ const user = await resolveUser(request);
414
+ if (!user)
415
+ throw new ApiError(401, "unauthorized", "not signed in");
416
+ if (!user.member)
417
+ throw new ApiError(403, "access_pending", "waiting for approval");
418
+ return user;
419
+ }
420
+ async function requireAdmin(user) {
421
+ if (user.via === "dev")
422
+ return;
423
+ if (!user.sub)
424
+ throw new ApiError(403, "forbidden", "admins only");
425
+ const cfg = current();
426
+ // A fresh answer for an admin action; when the provider cannot say, the
427
+ // last answer stands, as for every other check.
428
+ const answer = await membership.current(cfg.db(), user.sub, user.username, {
429
+ maxAgeMs: cfg.adminRecheckMaxAgeMs,
430
+ });
431
+ if (!answer.admin)
432
+ throw new ApiError(403, "forbidden", "admins only");
433
+ }
434
+ return {
435
+ getAuth,
436
+ async startSignIn(request) {
437
+ const cfg = current();
438
+ const url = new URL(request.url);
439
+ const returnTo = safeReturnPath(url.searchParams.get("return"));
440
+ const forceLogin = url.searchParams.has(LOGIN_PARAM);
441
+ const auth = await getAuthWithProvider();
442
+ if (!auth)
443
+ return errorResponse(503, "auth_unavailable", "sign-in is not available right now");
444
+ const paths = signInPaths(cfg.basePath);
445
+ const { headers, response } = await auth.api.signInSocial({
446
+ body: {
447
+ provider: cfg.providerId,
448
+ callbackURL: pathWithReturn(paths.finish, returnTo, forceLogin),
449
+ errorCallbackURL: paths.error,
450
+ disableRedirect: true,
451
+ ...(forceLogin ? { additionalParams: { prompt: "login" } } : {}),
452
+ },
453
+ headers: request.headers,
454
+ returnHeaders: true,
455
+ });
456
+ if (!response.url)
457
+ throw new Error("Better Auth returned no authorization URL");
458
+ return withCookies(redirect(response.url), headers);
459
+ },
460
+ finishSignIn(request) {
461
+ return redirect(safeReturnPath(new URL(request.url).searchParams.get("return")));
462
+ },
463
+ async handleAuthRequest(request) {
464
+ const auth = getAuth();
465
+ if (!auth)
466
+ return errorResponse(404, "not_found", "sign-in is not configured");
467
+ const response = await auth.handler(request);
468
+ if (response.status >= 500) {
469
+ logger("auth").warning("auth endpoint failed", {
470
+ path: new URL(request.url).pathname,
471
+ status: response.status,
472
+ });
473
+ }
474
+ return response;
475
+ },
476
+ signInRedirect(returnTo) {
477
+ if (!getAuth())
478
+ return null;
479
+ return redirect(pathWithReturn(signInPaths(current().basePath).start, returnTo, false));
480
+ },
481
+ startUrl(returnTo, forceLogin) {
482
+ return pathWithReturn(signInPaths(current().basePath).start, returnTo, forceLogin);
483
+ },
484
+ resolveUser,
485
+ requireUser,
486
+ async handleApi(request, fn, toError) {
487
+ const respond = toError ?? createErrorResponder({ logger: logger("api") });
488
+ try {
489
+ return await fn(await requireUser(request));
490
+ }
491
+ catch (err) {
492
+ return respond(err, request);
493
+ }
494
+ },
495
+ async accessStatus(request, opts = {}) {
496
+ let user = await resolveUser(request);
497
+ if (!user)
498
+ return {
499
+ signedIn: false,
500
+ username: null,
501
+ member: false,
502
+ admin: false,
503
+ requestedAt: null,
504
+ };
505
+ const cfg = current();
506
+ const db = cfg.db();
507
+ if (opts.recheck && user.sub) {
508
+ const answer = await membership.current(db, user.sub, user.username, {
509
+ maxAgeMs: 0,
510
+ });
511
+ user = { ...user, ...answer };
512
+ }
513
+ const pending = user.sub && !user.member ? await getAccessRequest(db, user.sub) : null;
514
+ return {
515
+ signedIn: true,
516
+ username: user.username,
517
+ member: user.member,
518
+ admin: user.admin,
519
+ requestedAt: pending?.status === "pending"
520
+ ? pending.requestedAt.toISOString()
521
+ : null,
522
+ };
523
+ },
524
+ requireAdmin,
525
+ async listPendingRequests(admin) {
526
+ await requireAdmin(admin);
527
+ const rows = await current()
528
+ .db()
529
+ .select()
530
+ .from(accessRequests)
531
+ .where(eq(accessRequests.status, "pending"))
532
+ .orderBy(asc(accessRequests.requestedAt));
533
+ return rows.map((row) => ({
534
+ sub: row.sub,
535
+ username: row.username,
536
+ email: row.email,
537
+ name: row.name,
538
+ requestedAt: row.requestedAt.toISOString(),
539
+ }));
540
+ },
541
+ async approveRequest(admin, sub) {
542
+ await requireAdmin(admin);
543
+ const cfg = current();
544
+ const db = cfg.db();
545
+ const request = await getAccessRequest(db, sub);
546
+ if (!request || request.status !== "pending")
547
+ throw new ApiError(404, "not_found", "no pending request for that person");
548
+ try {
549
+ const client = authentik();
550
+ const user = await client.findUser(request.sub, request.username);
551
+ if (!user || !user.isActive)
552
+ throw new ApiError(409, "conflict", "that Authentik account no longer exists");
553
+ await client.addToUsersGroup(user.pk);
554
+ await saveMemberCheck(db, request.sub, request.username, { member: true, admin: user.groups.includes(cfg.groups.admins) }, "api");
555
+ }
556
+ catch (err) {
557
+ if (err instanceof AuthentikUnavailable) {
558
+ logger("access").warning("approval failed: Authentik did not answer", { sub, error: err.message });
559
+ throw new ApiError(502, "authentik_unavailable", "Authentik did not accept the change; try again");
560
+ }
561
+ throw err;
562
+ }
563
+ await db
564
+ .update(accessRequests)
565
+ .set({
566
+ status: "approved",
567
+ decidedBy: admin.username,
568
+ decidedAt: new Date(),
569
+ })
570
+ .where(and(eq(accessRequests.sub, sub), eq(accessRequests.status, "pending")));
571
+ logger("access").info("access request approved", {
572
+ sub,
573
+ user: request.username,
574
+ by: admin.username,
575
+ });
576
+ },
577
+ currentMembership(sub, username, opts) {
578
+ return membership.current(current().db(), sub, username, opts);
579
+ },
580
+ csrfRejection(request) {
581
+ const cfg = current();
582
+ if (isSameOriginRequest(request, cfg.publicOrigins, cfg.streamPaths))
583
+ return null;
584
+ metrics().csrfRejections.inc();
585
+ return errorResponse(403, "forbidden", "cross-origin request refused");
586
+ },
587
+ };
588
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The few Authentik API calls an app makes (base `<apiUrl>/api/v3`):
3
+ *
4
+ * - find a user and their groups (reader token):
5
+ * `GET /core/users/?username=<u>&include_groups=true`
6
+ * - find the users group's pk by name (reader token), when it is not configured:
7
+ * `GET /core/groups/?name=<n>`
8
+ * - add a user to the users group (approver token):
9
+ * `POST /core/groups/<group_pk>/add_user/` with `{"pk": <user_pk>}`
10
+ *
11
+ * Users are keyed on `sub` and never authorized by name: a username lookup
12
+ * only counts when the user's `uid` equals the `sub` the app holds (the
13
+ * provider's sub_mode hashed_user_id makes `sub` the user's `uid`).
14
+ */
15
+ import type { Logger } from "@viktorbarzin/log";
16
+ import { type AuthentikSettings } from "./config.js";
17
+ /** The provider did not give an answer this module can believe. */
18
+ export declare class AuthentikUnavailable extends Error {
19
+ constructor(message: string);
20
+ }
21
+ /**
22
+ * The app cannot ask at all: no API URL or token configured. A configuration
23
+ * error, not an outage, so callers that keep the last answer through an
24
+ * outage must not keep it through this.
25
+ */
26
+ export declare class AuthentikNotConfigured extends AuthentikUnavailable {
27
+ constructor(message: string);
28
+ }
29
+ export interface AuthentikUser {
30
+ pk: number;
31
+ uid: string;
32
+ username: string;
33
+ isActive: boolean;
34
+ groups: string[];
35
+ }
36
+ export interface AuthentikClient {
37
+ findUser(sub: string, username: string): Promise<AuthentikUser | null>;
38
+ usersGroupPk(): Promise<string>;
39
+ addToUsersGroup(userPk: number): Promise<void>;
40
+ }
41
+ export declare function createAuthentikClient(cfg: AuthentikSettings & {
42
+ usersGroup: string;
43
+ logger?: Logger;
44
+ }): AuthentikClient;