@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,115 @@
1
+ import { DEFAULTS } from "./config.js";
2
+ /** The provider did not give an answer this module can believe. */
3
+ export class AuthentikUnavailable extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = "AuthentikUnavailable";
7
+ }
8
+ }
9
+ /**
10
+ * The app cannot ask at all: no API URL or token configured. A configuration
11
+ * error, not an outage, so callers that keep the last answer through an
12
+ * outage must not keep it through this.
13
+ */
14
+ export class AuthentikNotConfigured extends AuthentikUnavailable {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "AuthentikNotConfigured";
18
+ }
19
+ }
20
+ export function createAuthentikClient(cfg) {
21
+ const apiUrl = cfg.apiUrl?.replace(/\/+$/, "") ?? null;
22
+ const timeoutMs = cfg.timeoutMs ?? DEFAULTS.authentikTimeoutMs;
23
+ async function call(token, path, init = {}) {
24
+ if (!apiUrl)
25
+ throw new AuthentikNotConfigured("no Authentik API URL configured");
26
+ if (!token)
27
+ throw new AuthentikNotConfigured("no Authentik API token configured");
28
+ const headers = new Headers(init.headers);
29
+ headers.set("authorization", `Bearer ${token}`);
30
+ headers.set("accept", "application/json");
31
+ try {
32
+ return await fetch(`${apiUrl}/api/v3${path}`, {
33
+ ...init,
34
+ headers,
35
+ signal: AbortSignal.timeout(timeoutMs),
36
+ });
37
+ }
38
+ catch (err) {
39
+ throw new AuthentikUnavailable(`unreachable: ${String(err)}`);
40
+ }
41
+ }
42
+ async function readResults(response, what) {
43
+ // Any status but 200 is "cannot say": a token missing a permission
44
+ // answers 403, and reading that as "not a member" would lock people out.
45
+ if (response.status !== 200) {
46
+ throw new AuthentikUnavailable(`${what} answered ${response.status}`);
47
+ }
48
+ let body;
49
+ try {
50
+ body = await response.json();
51
+ }
52
+ catch {
53
+ throw new AuthentikUnavailable(`${what} did not answer JSON`);
54
+ }
55
+ const results = body?.results;
56
+ if (!Array.isArray(results)) {
57
+ throw new AuthentikUnavailable(`${what} answered something other than a list`);
58
+ }
59
+ return results;
60
+ }
61
+ async function usersGroupPk() {
62
+ if (cfg.usersGroupPk)
63
+ return cfg.usersGroupPk;
64
+ const query = new URLSearchParams({ name: cfg.usersGroup });
65
+ const response = await call(cfg.readerToken, `/core/groups/?${query}`);
66
+ for (const raw of await readResults(response, "group lookup")) {
67
+ const group = raw;
68
+ if (group.name === cfg.usersGroup && typeof group.pk === "string")
69
+ return group.pk;
70
+ }
71
+ throw new AuthentikUnavailable(`group "${cfg.usersGroup}" was not found`);
72
+ }
73
+ return {
74
+ async findUser(sub, username) {
75
+ const query = new URLSearchParams({ username, include_groups: "true" });
76
+ const response = await call(cfg.readerToken, `/core/users/?${query}`);
77
+ for (const raw of await readResults(response, "user lookup")) {
78
+ const found = raw;
79
+ if (found.username !== username || found.uid !== sub)
80
+ continue;
81
+ if (typeof found.pk !== "number")
82
+ continue;
83
+ const groups = Array.isArray(found.groups_obj)
84
+ ? found.groups_obj
85
+ .map((group) => group?.name)
86
+ .filter((name) => typeof name === "string")
87
+ : [];
88
+ return {
89
+ pk: found.pk,
90
+ uid: sub,
91
+ username,
92
+ isActive: found.is_active === true,
93
+ groups,
94
+ };
95
+ }
96
+ return null;
97
+ },
98
+ usersGroupPk,
99
+ async addToUsersGroup(userPk) {
100
+ const groupPk = await usersGroupPk();
101
+ const response = await call(cfg.approverToken, `/core/groups/${encodeURIComponent(groupPk)}/add_user/`, {
102
+ method: "POST",
103
+ headers: { "content-type": "application/json" },
104
+ body: JSON.stringify({ pk: userPk }),
105
+ });
106
+ if (response.status !== 204 && response.status !== 200) {
107
+ cfg.logger?.warning("adding a user to the users group failed", {
108
+ status: response.status,
109
+ group: cfg.usersGroup,
110
+ });
111
+ throw new AuthentikUnavailable(`add_user answered ${response.status}`);
112
+ }
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,120 @@
1
+ /** The configuration an app passes to createAppAuth, and the types it gets back. */
2
+ import type { Logger } from "@viktorbarzin/log";
3
+ import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
4
+ import type { Registry } from "prom-client";
5
+ /** Any Drizzle Postgres database whose schema includes the tables in @viktorbarzin/auth/schema. */
6
+ export type AuthDb = PgDatabase<PgQueryResultHKT, Record<string, unknown>>;
7
+ export interface AuthUser {
8
+ /** OIDC `sub`; null only for the dev user. */
9
+ sub: string | null;
10
+ /** `preferred_username`. */
11
+ username: string;
12
+ via: "session" | "token" | "dev";
13
+ /** In the users, admins or agents group, as of the last check. */
14
+ member: boolean;
15
+ /** In the admins group. */
16
+ admin: boolean;
17
+ }
18
+ export interface AccessStatus {
19
+ signedIn: boolean;
20
+ username: string | null;
21
+ member: boolean;
22
+ admin: boolean;
23
+ /** Set while an access request is waiting. */
24
+ requestedAt: string | null;
25
+ }
26
+ export interface PendingRequestView {
27
+ sub: string;
28
+ username: string;
29
+ email: string | null;
30
+ name: string | null;
31
+ requestedAt: string;
32
+ }
33
+ export interface GroupNames {
34
+ users: string;
35
+ admins: string;
36
+ agents?: string | null;
37
+ }
38
+ export interface AuthentikSettings {
39
+ /** The provider's base URL; `/api/v3` is appended. Null: re-checks fail closed. */
40
+ apiUrl: string | null;
41
+ /** A token that may view users and the users group (membership re-checks). */
42
+ readerToken: string | null;
43
+ /** A token that may only add users to the users group (approvals). */
44
+ approverToken: string | null;
45
+ /** The users group's pk; looked up by name when null. */
46
+ usersGroupPk: string | null;
47
+ timeoutMs?: number;
48
+ }
49
+ export interface OidcClientSettings {
50
+ /** The provider's `.well-known/openid-configuration` URL. */
51
+ discoveryUrl: string;
52
+ clientId: string;
53
+ clientSecret: string;
54
+ }
55
+ export interface AppAuthConfig<TExtra extends object = Record<never, never>> {
56
+ /** Shown by Better Auth, e.g. "Starter". */
57
+ appName: string;
58
+ /** DNS-label slug: cookie prefix, logger prefix and placeholder emails derive from it. */
59
+ appSlug: string;
60
+ db: () => AuthDb;
61
+ /** Exact origins; [0] is Better Auth's baseURL. Also the CSRF allow-list. */
62
+ publicOrigins: string[];
63
+ /** Signs session cookies and OAuth state. */
64
+ betterAuthSecret: string | null;
65
+ /** Null turns browser sign-in off (dev and tests on devUser). */
66
+ oidc: OidcClientSettings | null;
67
+ providerId?: string;
68
+ basePath?: string;
69
+ scopes?: string[];
70
+ sessionMaxAgeSeconds?: number;
71
+ cookiePrefix?: string;
72
+ /** The header the ingress sets to the client address (Traefik's real-ip plugin). */
73
+ clientIpHeader?: string;
74
+ /** Client IDs accepted as `aud` on bearer tokens. Defaults to [oidc.clientId]. */
75
+ apiTokenAudiences?: string[];
76
+ authentik: AuthentikSettings;
77
+ groups: GroupNames;
78
+ /** The provider's anonymous auto-login identity. */
79
+ guestUsername?: string;
80
+ membershipMaxAgeMs?: number;
81
+ adminRecheckMaxAgeMs?: number;
82
+ /** "request": a non-member gets an access_requests row. "open": the provider adds people to the group. */
83
+ signup: "request" | "open";
84
+ /**
85
+ * A stand-in user (member and admin) for callers with no session or token,
86
+ * for local dev and tests. Ignored, with an ERROR log, whenever `oidc` is
87
+ * set. The app passes it only in development and test.
88
+ */
89
+ devUser?: string | null;
90
+ /** Extra per-user fields, looked up on every resolved request and once at sign-in. */
91
+ enrich?: (db: AuthDb, who: {
92
+ sub: string | null;
93
+ username: string;
94
+ }) => Promise<TExtra>;
95
+ /** GET paths that get the CSRF origin check (SSE streams). */
96
+ streamPaths?: string[];
97
+ metrics?: {
98
+ registry?: Registry;
99
+ prefix?: string;
100
+ };
101
+ /** Logger names are `<loggerPrefix>.auth` etc. Defaults to appSlug. */
102
+ loggerPrefix?: string;
103
+ /** Overrides the loggers entirely (tests). */
104
+ logger?: Logger;
105
+ }
106
+ export declare const DEFAULTS: {
107
+ readonly providerId: "authentik";
108
+ readonly basePath: "/api/auth";
109
+ readonly scopes: readonly ["openid", "profile", "email"];
110
+ /** ADR-0001: sessions have an absolute cap of 7 days. */
111
+ readonly sessionMaxAgeSeconds: number;
112
+ readonly clientIpHeader: "x-real-ip";
113
+ readonly guestUsername: "guest";
114
+ /** ADR-0001: membership is re-asked after 15 minutes. */
115
+ readonly membershipMaxAgeMs: number;
116
+ readonly adminRecheckMaxAgeMs: number;
117
+ readonly streamPaths: readonly ["/api/events"];
118
+ readonly metricsPrefix: "auth";
119
+ readonly authentikTimeoutMs: 5000;
120
+ };
package/dist/config.js ADDED
@@ -0,0 +1,15 @@
1
+ export const DEFAULTS = {
2
+ providerId: "authentik",
3
+ basePath: "/api/auth",
4
+ scopes: ["openid", "profile", "email"],
5
+ /** ADR-0001: sessions have an absolute cap of 7 days. */
6
+ sessionMaxAgeSeconds: 7 * 24 * 60 * 60,
7
+ clientIpHeader: "x-real-ip",
8
+ guestUsername: "guest",
9
+ /** ADR-0001: membership is re-asked after 15 minutes. */
10
+ membershipMaxAgeMs: 15 * 60 * 1000,
11
+ adminRecheckMaxAgeMs: 60 * 1000,
12
+ streamPaths: ["/api/events"],
13
+ metricsPrefix: "auth",
14
+ authentikTimeoutMs: 5_000,
15
+ };
package/dist/csrf.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /** True for requests the origin check applies to. */
2
+ export declare function needsOriginCheck(request: Request, streamPaths?: readonly string[]): boolean;
3
+ /**
4
+ * True when the request may proceed. `allowed` empty means the request URL's
5
+ * own origin (dev, tests).
6
+ */
7
+ export declare function isSameOriginRequest(request: Request, allowed: string[], streamPaths?: string[]): boolean;
package/dist/csrf.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * CSRF protection: an explicit check of where a request comes from, for
3
+ * server functions, cookie-authenticated server routes and SSE streams.
4
+ *
5
+ * A request is checked when it can change state (any method but GET, HEAD,
6
+ * OPTIONS) or opens a stream path. Sibling sites on one registrable domain
7
+ * share SameSite=Lax cookies, so SameSite alone does not stop them. A checked
8
+ * request passes when:
9
+ * - it carries a bearer token (no ambient credential; identity then comes
10
+ * from the token only), or
11
+ * - the browser says `Sec-Fetch-Site: same-origin`, or
12
+ * - its `Origin` is one of the allowed origins exactly (scheme, host, port), or
13
+ * - it carries neither header nor a cookie (a non-browser client with nothing
14
+ * ambient to abuse).
15
+ * Everything else is refused, including `Origin: null`.
16
+ */
17
+ import { bearerToken } from "@viktorbarzin/oidc-token";
18
+ import { DEFAULTS } from "./config.js";
19
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
20
+ /** True for requests the origin check applies to. */
21
+ export function needsOriginCheck(request, streamPaths = DEFAULTS.streamPaths) {
22
+ if (!SAFE_METHODS.has(request.method.toUpperCase()))
23
+ return true;
24
+ return streamPaths.includes(new URL(request.url).pathname);
25
+ }
26
+ /**
27
+ * True when the request may proceed. `allowed` empty means the request URL's
28
+ * own origin (dev, tests).
29
+ */
30
+ export function isSameOriginRequest(request, allowed, streamPaths) {
31
+ if (!needsOriginCheck(request, streamPaths))
32
+ return true;
33
+ if (bearerToken(request) !== null)
34
+ return true;
35
+ const headers = request.headers;
36
+ const fetchSite = headers.get("sec-fetch-site");
37
+ if (fetchSite !== null)
38
+ return fetchSite === "same-origin";
39
+ const origin = headers.get("origin");
40
+ if (origin !== null) {
41
+ const origins = allowed.length > 0 ? allowed : [new URL(request.url).origin];
42
+ return origins.includes(origin);
43
+ }
44
+ return headers.get("cookie") === null;
45
+ }
@@ -0,0 +1,8 @@
1
+ export { type AppAuth, type BetterAuthInstance, createAppAuth, DISABLED_PATHS, } from "./app-auth.js";
2
+ export { type AuthentikClient, AuthentikNotConfigured, AuthentikUnavailable, type AuthentikUser, createAuthentikClient, } from "./authentik.js";
3
+ export type { AccessStatus, AppAuthConfig, AuthDb, AuthentikSettings, AuthUser, GroupNames, OidcClientSettings, PendingRequestView, } from "./config.js";
4
+ export { DEFAULTS } from "./config.js";
5
+ export { isSameOriginRequest, needsOriginCheck } from "./csrf.js";
6
+ export { type Membership, type MembershipOptions, membershipFromGroups, } from "./membership.js";
7
+ export { type AuthMetrics, authMetrics } from "./metrics.js";
8
+ export { safeReturnPath } from "./return-path.js";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { createAppAuth, DISABLED_PATHS, } from "./app-auth.js";
2
+ export { AuthentikNotConfigured, AuthentikUnavailable, createAuthentikClient, } from "./authentik.js";
3
+ export { DEFAULTS } from "./config.js";
4
+ export { isSameOriginRequest, needsOriginCheck } from "./csrf.js";
5
+ export { membershipFromGroups, } from "./membership.js";
6
+ export { authMetrics } from "./metrics.js";
7
+ export { safeReturnPath } from "./return-path.js";
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Membership: may this subject use the app, and administer it?
3
+ *
4
+ * Each request re-checks group membership with the provider once the stored
5
+ * answer is older than 15 minutes, so removing someone takes effect within
6
+ * that window. Web sign-in requests no `offline_access`, so the provider
7
+ * issues no refresh token; this re-check is the app's "refresh".
8
+ *
9
+ * The first answer comes from the verified id_token's `groups` claim at
10
+ * sign-in (or a bearer token's), so a fresh sign-in needs no API call.
11
+ *
12
+ * Failure direction: when the provider cannot answer (network error, any
13
+ * status but 200), the last answer stands. Only an answer the provider gave
14
+ * can remove access; with no earlier answer the subject is not a member. A
15
+ * missing API URL or reader token is different: the app could never
16
+ * re-check, so that case fails closed and logs an ERROR on every re-check.
17
+ */
18
+ import type { Logger } from "@viktorbarzin/log";
19
+ import { type AuthentikClient } from "./authentik.js";
20
+ import type { AuthDb, GroupNames } from "./config.js";
21
+ import type { AuthMetrics } from "./metrics.js";
22
+ import { type AccessRequest, type MemberCheck } from "./schema.js";
23
+ export interface Membership {
24
+ member: boolean;
25
+ admin: boolean;
26
+ }
27
+ /** Members are in the users, admins or agents group; admins are in the admins group. */
28
+ export declare function membershipFromGroups(groups: readonly string[], cfg: GroupNames): Membership;
29
+ export declare function readMemberCheck(db: AuthDb, sub: string): Promise<MemberCheck | null>;
30
+ export declare function saveMemberCheck(db: AuthDb, sub: string, username: string, membership: Membership, source: "id_token" | "token" | "api", checkedAt?: Date): Promise<void>;
31
+ /**
32
+ * Record an answer that came with a verified token (`groups` claim). A token
33
+ * without the claim says nothing about membership and changes nothing.
34
+ */
35
+ export declare function recordClaimedGroups(db: AuthDb, sub: string, username: string, groups: unknown, source: "id_token" | "token", cfg: GroupNames): Promise<void>;
36
+ export interface MembershipOptions {
37
+ /** Re-ask when the stored answer is older than this. 0 forces a fresh answer. */
38
+ maxAgeMs?: number;
39
+ now?: () => number;
40
+ }
41
+ export interface MembershipChecker {
42
+ current(db: AuthDb, sub: string, username: string, opts?: MembershipOptions): Promise<Membership>;
43
+ }
44
+ export declare function createMembershipChecker(deps: {
45
+ authentik: () => AuthentikClient;
46
+ groups: () => GroupNames;
47
+ defaultMaxAgeMs: () => number;
48
+ metrics: () => AuthMetrics;
49
+ log: Logger;
50
+ }): MembershipChecker;
51
+ export interface RequestDetails {
52
+ sub: string;
53
+ username: string;
54
+ email: string | null;
55
+ name: string | null;
56
+ }
57
+ /** File (or refresh) a pending request. An approved request is left alone. */
58
+ export declare function fileAccessRequest(db: AuthDb, details: RequestDetails): Promise<void>;
59
+ export declare function getAccessRequest(db: AuthDb, sub: string): Promise<AccessRequest | null>;
@@ -0,0 +1,118 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { AuthentikNotConfigured, AuthentikUnavailable, } from "./authentik.js";
3
+ import { accessRequests, memberChecks, } from "./schema.js";
4
+ /** Members are in the users, admins or agents group; admins are in the admins group. */
5
+ export function membershipFromGroups(groups, cfg) {
6
+ const set = new Set(groups);
7
+ const admin = set.has(cfg.admins);
8
+ const agent = cfg.agents ? set.has(cfg.agents) : false;
9
+ return { member: admin || agent || set.has(cfg.users), admin };
10
+ }
11
+ export async function readMemberCheck(db, sub) {
12
+ const rows = await db
13
+ .select()
14
+ .from(memberChecks)
15
+ .where(eq(memberChecks.sub, sub))
16
+ .limit(1);
17
+ return rows[0] ?? null;
18
+ }
19
+ export async function saveMemberCheck(db, sub, username, membership, source, checkedAt = new Date()) {
20
+ await db
21
+ .insert(memberChecks)
22
+ .values({ sub, username, ...membership, source, checkedAt })
23
+ .onConflictDoUpdate({
24
+ target: memberChecks.sub,
25
+ set: { username, ...membership, source, checkedAt },
26
+ });
27
+ }
28
+ /**
29
+ * Record an answer that came with a verified token (`groups` claim). A token
30
+ * without the claim says nothing about membership and changes nothing.
31
+ */
32
+ export async function recordClaimedGroups(db, sub, username, groups, source, cfg) {
33
+ if (!Array.isArray(groups))
34
+ return;
35
+ const names = groups.filter((g) => typeof g === "string");
36
+ await saveMemberCheck(db, sub, username, membershipFromGroups(names, cfg), source);
37
+ }
38
+ export function createMembershipChecker(deps) {
39
+ const inFlight = new Map();
40
+ async function recheck(db, sub, username, last) {
41
+ try {
42
+ const user = await deps.authentik().findUser(sub, username);
43
+ const answer = user?.isActive === true
44
+ ? membershipFromGroups(user.groups, deps.groups())
45
+ : { member: false, admin: false };
46
+ deps.metrics().membershipChecks.inc({
47
+ outcome: answer.member ? "member" : "removed",
48
+ });
49
+ if (last?.member && !answer.member)
50
+ deps.log.info("membership: no longer a member", {
51
+ sub,
52
+ user: username,
53
+ });
54
+ await saveMemberCheck(db, sub, username, answer, "api");
55
+ return answer;
56
+ }
57
+ catch (err) {
58
+ if (err instanceof AuthentikNotConfigured) {
59
+ deps.metrics().membershipChecks.inc({ outcome: "misconfigured" });
60
+ deps.log.error("membership: cannot re-check, access refused", {
61
+ sub,
62
+ user: username,
63
+ error: err.message,
64
+ });
65
+ return { member: false, admin: false };
66
+ }
67
+ if (!(err instanceof AuthentikUnavailable))
68
+ throw err;
69
+ deps.metrics().membershipChecks.inc({ outcome: "unreachable" });
70
+ deps.log.warning("membership: cannot check, the last answer stands", {
71
+ sub,
72
+ user: username,
73
+ error: err.message,
74
+ });
75
+ return last
76
+ ? { member: last.member, admin: last.admin }
77
+ : { member: false, admin: false };
78
+ }
79
+ }
80
+ return {
81
+ async current(db, sub, username, opts = {}) {
82
+ const maxAge = opts.maxAgeMs ?? deps.defaultMaxAgeMs();
83
+ const now = opts.now ?? Date.now;
84
+ const last = await readMemberCheck(db, sub);
85
+ if (last && maxAge > 0 && now() - last.checkedAt.getTime() < maxAge) {
86
+ return { member: last.member, admin: last.admin };
87
+ }
88
+ const pending = inFlight.get(sub);
89
+ if (pending)
90
+ return pending;
91
+ const started = recheck(db, sub, username, last).finally(() => inFlight.delete(sub));
92
+ inFlight.set(sub, started);
93
+ return started;
94
+ },
95
+ };
96
+ }
97
+ /** File (or refresh) a pending request. An approved request is left alone. */
98
+ export async function fileAccessRequest(db, details) {
99
+ await db
100
+ .insert(accessRequests)
101
+ .values({ ...details, status: "pending" })
102
+ .onConflictDoUpdate({
103
+ target: accessRequests.sub,
104
+ set: {
105
+ username: details.username,
106
+ email: details.email,
107
+ name: details.name,
108
+ },
109
+ });
110
+ }
111
+ export async function getAccessRequest(db, sub) {
112
+ const rows = await db
113
+ .select()
114
+ .from(accessRequests)
115
+ .where(eq(accessRequests.sub, sub))
116
+ .limit(1);
117
+ return rows[0] ?? null;
118
+ }
@@ -0,0 +1,14 @@
1
+ import { type Counter, type Registry } from "prom-client";
2
+ export interface AuthMetrics {
3
+ /** Re-checks: member, removed, unreachable (last answer stood), misconfigured (refused). */
4
+ membershipChecks: Counter<"outcome">;
5
+ /** Bearer-token verifications: ok, or why one was refused. */
6
+ tokenVerifications: Counter<"outcome">;
7
+ csrfRejections: Counter<string>;
8
+ /** Sign-ins: member, pending, or guest (sent back with prompt=login). */
9
+ signIns: Counter<"outcome">;
10
+ }
11
+ export declare function authMetrics(opts?: {
12
+ registry?: Registry;
13
+ prefix?: string;
14
+ }): AuthMetrics;
@@ -0,0 +1,19 @@
1
+ /** Identity metrics, registered once per registry and prefix. */
2
+ import { getRegistry } from "@viktorbarzin/metrics";
3
+ import client, {} from "prom-client";
4
+ function counter(registry, name, help, labelNames) {
5
+ const existing = registry.getSingleMetric(name);
6
+ if (existing)
7
+ return existing;
8
+ return new client.Counter({ name, help, labelNames, registers: [registry] });
9
+ }
10
+ export function authMetrics(opts = {}) {
11
+ const registry = opts.registry ?? getRegistry();
12
+ const p = opts.prefix ?? "auth";
13
+ return {
14
+ membershipChecks: counter(registry, `${p}_membership_checks_total`, "Membership re-checks against the identity provider by outcome.", ["outcome"]),
15
+ tokenVerifications: counter(registry, `${p}_api_token_verifications_total`, "Bearer-token verifications by outcome.", ["outcome"]),
16
+ csrfRejections: counter(registry, `${p}_csrf_rejections_total`, "Requests refused by the CSRF origin check.", []),
17
+ signIns: counter(registry, `${p}_sign_ins_total`, "OIDC sign-ins completed by outcome.", ["outcome"]),
18
+ };
19
+ }
@@ -0,0 +1,16 @@
1
+ /** Where to send a browser after sign-in: same-origin paths only. */
2
+ /**
3
+ * A relative path inside the app to return to after sign-in, or "/".
4
+ * Refuses absolute and protocol-relative URLs (no open redirect). The check
5
+ * runs on the normalised result too: dot segments collapse, so `/.//evil`
6
+ * normalises to `//evil`.
7
+ */
8
+ export declare function safeReturnPath(raw: string | null | undefined): string;
9
+ /** Marks a sign-in that already asked the provider for `prompt=login`. */
10
+ export declare const LOGIN_PARAM = "login";
11
+ export declare function signInPaths(basePath: string): {
12
+ start: string;
13
+ finish: string;
14
+ error: string;
15
+ };
16
+ export declare function pathWithReturn(path: string, returnTo: string, login: boolean): string;
@@ -0,0 +1,40 @@
1
+ /** Where to send a browser after sign-in: same-origin paths only. */
2
+ /** True for a value a browser would resolve against another host. */
3
+ function leavesOrigin(path) {
4
+ return path.startsWith("//") || path.startsWith("/\\");
5
+ }
6
+ /**
7
+ * A relative path inside the app to return to after sign-in, or "/".
8
+ * Refuses absolute and protocol-relative URLs (no open redirect). The check
9
+ * runs on the normalised result too: dot segments collapse, so `/.//evil`
10
+ * normalises to `//evil`.
11
+ */
12
+ export function safeReturnPath(raw) {
13
+ if (!raw || !raw.startsWith("/") || leavesOrigin(raw))
14
+ return "/";
15
+ try {
16
+ const url = new URL(raw, "http://app.invalid");
17
+ if (url.origin !== "http://app.invalid")
18
+ return "/";
19
+ const path = `${url.pathname}${url.search}`;
20
+ return leavesOrigin(path) ? "/" : path;
21
+ }
22
+ catch {
23
+ return "/";
24
+ }
25
+ }
26
+ /** Marks a sign-in that already asked the provider for `prompt=login`. */
27
+ export const LOGIN_PARAM = "login";
28
+ export function signInPaths(basePath) {
29
+ return {
30
+ start: `${basePath}/start`,
31
+ finish: `${basePath}/finish`,
32
+ error: `${basePath}/error`,
33
+ };
34
+ }
35
+ export function pathWithReturn(path, returnTo, login) {
36
+ const query = new URLSearchParams({ return: safeReturnPath(returnTo) });
37
+ if (login)
38
+ query.set(LOGIN_PARAM, "1");
39
+ return `${path}?${query}`;
40
+ }