@micacms/auth-oidc 0.5.0 → 0.6.0-rc.1

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,132 @@
1
+ /**
2
+ * Admission — who may edit this site.
3
+ *
4
+ * OpenID Connect states who a person is. It says nothing about whether that
5
+ * person may edit this website: a provider vouches for a whole company, and a
6
+ * perfectly valid ID token is the beginning of the question rather than the
7
+ * answer to it.
8
+ *
9
+ * Mica has no accounts, so admission is configuration:
10
+ *
11
+ * MICA_ADMIT_GROUPS=mica-editors a claim the provider sends
12
+ * MICA_ADMIT_DOMAINS=example.com every verified address there
13
+ * MICA_ADMIT_EMAILS=alice@gmail.com these verified addresses
14
+ * MICA_ADMIT_SUBJECTS=https://id.example.com|a1b2c3
15
+ *
16
+ * The group rule is the one to grow into. Adding an editor is then a group
17
+ * change in the identity provider — where the operator already manages
18
+ * joiners and leavers — and Mica is not touched, not restarted, and holds no
19
+ * copy of the list to go stale. The email list exists for a provider with no
20
+ * groups, such as Google; the subject list for one that verifies no addresses.
21
+ *
22
+ * What this costs, and it is a real cost: removing somebody from a group at
23
+ * the provider does not take effect until their next sign-in, because the
24
+ * claims Mica checks are the ones it was given. Configuration changes *are*
25
+ * immediate — this policy is re-read on every request — and the session
26
+ * lifetime is the upper bound on the rest. docs/decisions.md records the trade.
27
+ */
28
+
29
+ import type { AuthConfig } from "./config.js";
30
+ import type { SessionIdentity } from "./session.js";
31
+
32
+ export type AdmissionResult = { admitted: true } | { admitted: false; reason: string };
33
+
34
+ export interface AdmissionPolicy {
35
+ /** One line for the startup log, so an operator can see what is in force. */
36
+ readonly summary: string;
37
+ admits(identity: SessionIdentity): AdmissionResult;
38
+ }
39
+
40
+ /**
41
+ * The strictest rule: the subject never changes and is never reassigned. The
42
+ * issuer is part of the key because a subject is only unique within the
43
+ * provider that issued it.
44
+ */
45
+ function matchesSubject(
46
+ identity: SessionIdentity,
47
+ entries: ReadonlyArray<{ issuer: string; subject: string }>,
48
+ ): boolean {
49
+ return entries.some(
50
+ (entry) => entry.issuer === identity.issuer && entry.subject === identity.subject,
51
+ );
52
+ }
53
+
54
+ function matchesEmail(identity: SessionIdentity, emails: readonly string[]): boolean {
55
+ return emails.includes(identity.email.toLowerCase());
56
+ }
57
+
58
+ function matchesDomain(identity: SessionIdentity, domains: readonly string[]): boolean {
59
+ const at = identity.email.lastIndexOf("@");
60
+ if (at < 0) return false;
61
+ return domains.includes(identity.email.slice(at + 1).toLowerCase());
62
+ }
63
+
64
+ /**
65
+ * Group names are compared case-sensitively, and a leading `/` is ignored.
66
+ *
67
+ * Keycloak sends group paths — `/mica-editors` — while most other providers
68
+ * send bare names. Accepting both means an operator writes the name they see
69
+ * in their own console, which is the only name they have.
70
+ */
71
+ function matchesGroup(identity: SessionIdentity, groups: readonly string[]): boolean {
72
+ const held = new Set(identity.groups.map((group) => group.replace(/^\//, "")));
73
+ return groups.some((group) => held.has(group.replace(/^\//, "")));
74
+ }
75
+
76
+ export function createAdmissionPolicy(
77
+ config: Pick<AuthConfig, "admitGroups" | "admitDomains" | "admitEmails" | "admitSubjects">,
78
+ ): AdmissionPolicy {
79
+ const { admitGroups, admitDomains, admitEmails, admitSubjects } = config;
80
+
81
+ const summary = [
82
+ admitGroups.length > 0 ? `group=${admitGroups.join("|")}` : null,
83
+ admitDomains.length > 0 ? `domain=${admitDomains.join("|")}` : null,
84
+ admitEmails.length > 0 ? `emails=${admitEmails.length}` : null,
85
+ admitSubjects.length > 0 ? `subjects=${admitSubjects.length}` : null,
86
+ ]
87
+ .filter(Boolean)
88
+ .join(", ");
89
+
90
+ return {
91
+ summary,
92
+
93
+ admits(identity) {
94
+ // The explicit list first: it is the escape hatch, and it has to work
95
+ // when the group claim is missing or the address is unverified.
96
+ if (matchesSubject(identity, admitSubjects)) return { admitted: true };
97
+
98
+ if (matchesGroup(identity, admitGroups)) return { admitted: true };
99
+
100
+ const byEmail = matchesEmail(identity, admitEmails);
101
+ if (byEmail || matchesDomain(identity, admitDomains)) {
102
+ /**
103
+ * An email or domain rule trusts the provider to have checked the address.
104
+ *
105
+ * Where a provider allows self-registration, an unverified address is
106
+ * a claim the person typed. Admitting on that would let anybody enter
107
+ * an address they do not hold, so the rule refuses and says why — the
108
+ * operator can turn verification on, or use a group or subject rule
109
+ * instead.
110
+ *
111
+ * What remains is reassignment: an organisation that gives a former
112
+ * editor's address to somebody new gives them this site too. Public
113
+ * providers such as Gmail never reuse an address; the trade is
114
+ * recorded in docs/decisions.md.
115
+ */
116
+ if (identity.emailVerified) return { admitted: true };
117
+
118
+ return {
119
+ admitted: false,
120
+ reason:
121
+ `${identity.email} is ${byEmail ? "an admitted address" : "in an admitted domain"}, ` +
122
+ "but the identity provider does not report the address as verified.",
123
+ };
124
+ }
125
+
126
+ return {
127
+ admitted: false,
128
+ reason: `${identity.email || identity.subject} is not admitted to this site.`,
129
+ };
130
+ },
131
+ };
132
+ }
package/src/config.ts ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * The auth gate's slice of the environment.
3
+ *
4
+ * A fragment, like the runtime's: the self-hosted app spreads `authEnv` into
5
+ * its own schema, derives with `deriveAuth`, and passes the result here. The
6
+ * local editor never touches any of this — these variables do not exist in
7
+ * its configuration at all, which is a stronger statement than ignoring them.
8
+ */
9
+
10
+ import { z } from "zod";
11
+
12
+ import { ConfigError, list } from "@micacms/runtime";
13
+
14
+ export const authEnv = {
15
+ /**
16
+ * OpenID Connect. Mica implements no passwords, no reset flow and no second
17
+ * factor. It asks a provider who you are; the admission rules below decide
18
+ * whether that person may edit this site.
19
+ */
20
+ MICA_OIDC_ISSUER: z.string().url().optional(),
21
+ MICA_OIDC_CLIENT_ID: z.string().optional(),
22
+ /** Omit for a public client using PKCE alone. */
23
+ MICA_OIDC_CLIENT_SECRET: z.string().optional(),
24
+ MICA_OIDC_SCOPE: z.string().default("openid profile email"),
25
+ /**
26
+ * Which claim carries the person's groups. There is no standard one:
27
+ * Keycloak and Entra normally send `groups`, others send `roles`, and some
28
+ * send whatever the operator mapped. Naming it here is what keeps
29
+ * `MICA_ADMIT_GROUPS` working against any provider.
30
+ */
31
+ MICA_OIDC_GROUPS_CLAIM: z.string().default("groups"),
32
+
33
+ /**
34
+ * Who may edit this site. Authentication says who somebody is; these say
35
+ * whether that person gets in. At least one is required — the composition
36
+ * refuses to start without one. Comma-separated, all four.
37
+ */
38
+ MICA_ADMIT_GROUPS: z.string().optional(),
39
+ MICA_ADMIT_DOMAINS: z.string().optional(),
40
+ /** Verified addresses — the list for a provider with no groups, such as Google. */
41
+ MICA_ADMIT_EMAILS: z.string().optional(),
42
+ /** `issuer|subject` pairs — see admission.ts. */
43
+ MICA_ADMIT_SUBJECTS: z.string().optional(),
44
+
45
+ /** Signs session cookies. Rotating it invalidates every session. */
46
+ MICA_SESSION_SECRET: z.string().min(32).optional(),
47
+ /**
48
+ * Deliberately short. Admission is configuration and is re-read on every
49
+ * request; what cannot be immediate is a change *at the provider*, which
50
+ * Mica only sees at the next sign-in. The session lifetime is therefore the
51
+ * upper bound on how long a revoked editor keeps working.
52
+ */
53
+ MICA_SESSION_TTL_HOURS: z.coerce.number().int().positive().default(8),
54
+ MICA_SESSION_IDLE_HOURS: z.coerce.number().int().positive().default(4),
55
+
56
+ MICA_SIGNIN_MAX_ATTEMPTS: z.coerce.number().int().positive().default(10),
57
+ MICA_SIGNIN_WINDOW_MS: z.coerce
58
+ .number()
59
+ .int()
60
+ .positive()
61
+ .default(15 * 60 * 1000),
62
+ };
63
+
64
+ const authSchema = z.object(authEnv);
65
+ export type AuthEnv = z.infer<typeof authSchema>;
66
+
67
+ /** The derived values the gate reads, on top of the parsed environment. */
68
+ export function deriveAuth<T extends AuthEnv & { NODE_ENV: string; MICA_PUBLIC_URL: string }>(
69
+ env: T,
70
+ ) {
71
+ return {
72
+ ...env,
73
+ /**
74
+ * Discovery over plain HTTP, for an identity provider running on
75
+ * localhost. Never in production, and never for a remote host.
76
+ */
77
+ oidcAllowInsecure:
78
+ env.NODE_ENV !== "production" &&
79
+ /^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(env.MICA_OIDC_ISSUER ?? ""),
80
+ admitGroups: list(env.MICA_ADMIT_GROUPS),
81
+ // Lowercased: a domain is case-insensitive, and an operator who writes
82
+ // `Example.com` means the same thing the provider spells `example.com`.
83
+ admitDomains: list(env.MICA_ADMIT_DOMAINS).map((domain) =>
84
+ domain.toLowerCase().replace(/^@/, ""),
85
+ ),
86
+ admitEmails: list(env.MICA_ADMIT_EMAILS).map((email) => email.toLowerCase()),
87
+ admitSubjects: list(env.MICA_ADMIT_SUBJECTS).map((entry) => {
88
+ const separator = entry.indexOf("|");
89
+ return {
90
+ issuer: separator < 0 ? "" : entry.slice(0, separator).trim(),
91
+ subject: separator < 0 ? entry : entry.slice(separator + 1).trim(),
92
+ };
93
+ }),
94
+ // Secure cookies are not sent over plain HTTP, which would break a
95
+ // localhost trial run.
96
+ cookieSecure: env.MICA_PUBLIC_URL.startsWith("https://"),
97
+ };
98
+ }
99
+
100
+ /** What the gate requires of a finished configuration. */
101
+ export interface AuthConfig {
102
+ MICA_PUBLIC_URL: string;
103
+ isProduction: boolean;
104
+ MICA_OIDC_ISSUER?: string | undefined;
105
+ MICA_OIDC_CLIENT_ID?: string | undefined;
106
+ MICA_OIDC_CLIENT_SECRET?: string | undefined;
107
+ MICA_OIDC_SCOPE: string;
108
+ MICA_OIDC_GROUPS_CLAIM: string;
109
+ oidcAllowInsecure: boolean;
110
+ admitGroups: string[];
111
+ admitDomains: string[];
112
+ admitEmails: string[];
113
+ admitSubjects: Array<{ issuer: string; subject: string }>;
114
+ /** Resolved by the app: mandatory in production, ephemeral in development. */
115
+ sessionSecret: string;
116
+ cookieSecure: boolean;
117
+ MICA_SESSION_TTL_HOURS: number;
118
+ MICA_SESSION_IDLE_HOURS: number;
119
+ MICA_SIGNIN_MAX_ATTEMPTS: number;
120
+ MICA_SIGNIN_WINDOW_MS: number;
121
+ }
122
+
123
+ /**
124
+ * Domains where anybody can register an address. A domain rule naming one
125
+ * admits the public, so it is refused; name the addresses instead.
126
+ */
127
+ const PUBLIC_EMAIL_DOMAINS = new Set([
128
+ "gmail.com",
129
+ "googlemail.com",
130
+ "outlook.com",
131
+ "hotmail.com",
132
+ "live.com",
133
+ "msn.com",
134
+ "yahoo.com",
135
+ "icloud.com",
136
+ "me.com",
137
+ "aol.com",
138
+ "proton.me",
139
+ "protonmail.com",
140
+ "gmx.com",
141
+ "gmx.net",
142
+ "gmx.de",
143
+ "gmx.ch",
144
+ "web.de",
145
+ ]);
146
+
147
+ /**
148
+ * The refusals that make a deployment honest before it serves a request.
149
+ *
150
+ * An OIDC deployment that admits every person its issuer knows is a
151
+ * misconfiguration, not a default — see decisions.md.
152
+ */
153
+ export function requireAuthConfig(config: {
154
+ MICA_PUBLIC_URL: string;
155
+ MICA_OIDC_ISSUER?: string | undefined;
156
+ MICA_OIDC_CLIENT_ID?: string | undefined;
157
+ admitGroups: string[];
158
+ admitDomains: string[];
159
+ admitEmails: string[];
160
+ admitSubjects: Array<{ issuer: string; subject: string }>;
161
+ }): void {
162
+ const needed = (["MICA_OIDC_ISSUER", "MICA_OIDC_CLIENT_ID"] as const).filter(
163
+ (key) => !config[key],
164
+ );
165
+
166
+ if (needed.length > 0) {
167
+ throw new ConfigError(
168
+ `This deployment authenticates with OpenID Connect, so ` +
169
+ `${needed.join(" and ")} must be set.\n` +
170
+ " Mica implements no passwords. Point it at Keycloak, Authentik,\n" +
171
+ " ZITADEL, Entra, Okta, Google — anything that speaks OIDC.\n" +
172
+ ` The redirect URI to register is ${config.MICA_PUBLIC_URL}/api/auth/callback`,
173
+ );
174
+ }
175
+
176
+ if (
177
+ config.admitGroups.length === 0 &&
178
+ config.admitDomains.length === 0 &&
179
+ config.admitEmails.length === 0 &&
180
+ config.admitSubjects.length === 0
181
+ ) {
182
+ throw new ConfigError(
183
+ "Nothing says who may edit this site.\n" +
184
+ " Authentication is not admission: your provider will happily vouch for\n" +
185
+ " everybody it knows. Set at least one of these:\n\n" +
186
+ " MICA_ADMIT_GROUPS=mica-editors a claim your provider sends\n" +
187
+ " MICA_ADMIT_DOMAINS=example.com every verified address there\n" +
188
+ " MICA_ADMIT_EMAILS=alice@gmail.com these verified addresses\n" +
189
+ " MICA_ADMIT_SUBJECTS=https://id.example.com|a1b2c3\n\n" +
190
+ " The group rule is the one to grow into: adding an editor is then a\n" +
191
+ " change in the identity provider, and Mica is not touched.",
192
+ );
193
+ }
194
+
195
+ const open = config.admitDomains.filter((domain) => PUBLIC_EMAIL_DOMAINS.has(domain));
196
+ if (open.length > 0) {
197
+ throw new ConfigError(
198
+ `MICA_ADMIT_DOMAINS names ${open.join(", ")}, where anybody can register an address.\n` +
199
+ " That rule would let the public edit this site. Name the people instead:\n" +
200
+ " MICA_ADMIT_EMAILS=alice@gmail.com,bob@gmail.com",
201
+ );
202
+ }
203
+
204
+ const malformed = config.admitEmails.filter((email) => !/^[^@\s]+@[^@\s]+$/.test(email));
205
+ if (malformed.length > 0) {
206
+ throw new ConfigError(
207
+ `MICA_ADMIT_EMAILS entries are email addresses, and these are not: ${malformed.join(", ")}`,
208
+ );
209
+ }
210
+
211
+ const wrong = config.admitSubjects.filter((entry) => !entry.issuer || !entry.subject);
212
+ if (wrong.length > 0) {
213
+ throw new ConfigError(
214
+ "MICA_ADMIT_SUBJECTS entries are `issuer|subject` pairs, and one is not:\n" +
215
+ ` ${wrong.map((entry) => entry.subject || "(empty)").join(", ")}\n\n` +
216
+ " The issuer is part of the key because a subject is only unique within\n" +
217
+ " the provider that issued it. To admit an address, use MICA_ADMIT_EMAILS.\n" +
218
+ ` MICA_ADMIT_SUBJECTS=${config.MICA_OIDC_ISSUER ?? "https://id.example.com"}|a1b2c3`,
219
+ );
220
+ }
221
+ }
package/src/gate.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The gate: what stands between a request and the admin API.
3
+ *
4
+ * Two layers, in order. `requireAccess` turns a session cookie into an
5
+ * `AccessContext` — or a 401 — re-checking admission on every request so a
6
+ * configuration change is immediate, and re-issuing the cookie while the
7
+ * session is in use. `csrfProtection` is the double-submit
8
+ * token over the Origin check the runtime already applies: cross-origin
9
+ * JavaScript can neither read the cookie nor set the header.
10
+ */
11
+
12
+ import { getCookie, setCookie } from "hono/cookie";
13
+ import type { Context, MiddlewareHandler } from "hono";
14
+
15
+ import { ApiError, type Env } from "@micacms/runtime";
16
+
17
+ import type { AdmissionPolicy } from "./admission.js";
18
+ import {
19
+ CSRF_HEADER,
20
+ safeEqual,
21
+ type SessionRecord,
22
+ type SessionService,
23
+ } from "./session.js";
24
+
25
+ /**
26
+ * The session behind the current request. Auth's own context variable —
27
+ * the runtime knows only the `access` it derives from it.
28
+ */
29
+ const SESSION_VARIABLE = "authSession";
30
+
31
+ export function sessionOf(c: Context<Env>): SessionRecord {
32
+ // Set by requireAccess, which runs in front of everything that calls this.
33
+ return c.get(SESSION_VARIABLE as never) as SessionRecord;
34
+ }
35
+
36
+ export interface GateDeps {
37
+ sessions: SessionService;
38
+ admission: AdmissionPolicy;
39
+ }
40
+
41
+ /**
42
+ * Rejects the request unless there is a live session whose person is still
43
+ * admitted.
44
+ *
45
+ * Admission is re-read on every request, not decided once at sign-in. That is
46
+ * what makes a configuration change immediate: an operator who takes somebody
47
+ * out of `MICA_ADMIT_SUBJECTS` and restarts has removed them. What it cannot
48
+ * make immediate is a group removal at the provider — the claims here are the
49
+ * ones the provider sent, and Mica learns better at the next sign-in.
50
+ */
51
+ export function requireAccess(deps: GateDeps): MiddlewareHandler<Env> {
52
+ return async (c, next) => {
53
+ let session = deps.sessions.resolve(getCookie(c, deps.sessions.names.session));
54
+
55
+ if (!session) {
56
+ throw new ApiError("unauthenticated", "Your session has expired. Please sign in again.");
57
+ }
58
+
59
+ const { identity } = session;
60
+
61
+ const verdict = deps.admission.admits(identity);
62
+ if (!verdict.admitted) {
63
+ c.get("logger").warn("Session no longer admitted", {
64
+ subject: identity.subject,
65
+ reason: verdict.reason,
66
+ });
67
+ throw new ApiError("forbidden", "You no longer have access to this site.");
68
+ }
69
+
70
+ const refreshed = deps.sessions.refresh(session);
71
+ if (refreshed) session = refreshed.record;
72
+
73
+ c.set(SESSION_VARIABLE as never, session as never);
74
+ c.set("access", {
75
+ identity: { subject: identity.subject, email: identity.email, name: identity.name },
76
+ });
77
+ await next();
78
+
79
+ // After the handler, so the cookie lands on whatever response it built.
80
+ if (refreshed) {
81
+ setCookie(c, deps.sessions.names.session, refreshed.cookie, deps.sessions.sessionCookieOptions());
82
+ }
83
+ };
84
+ }
85
+
86
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
87
+
88
+ /**
89
+ * Second CSRF layer: double-submit token. The session's token is issued in a
90
+ * cookie the SPA can read and must be echoed in a header.
91
+ *
92
+ * Runs after `requireAccess`, and complements the session cookie's
93
+ * `SameSite=Lax`, which already blocks the classic cross-site form post.
94
+ */
95
+ export function csrfProtection(deps: GateDeps): MiddlewareHandler<Env> {
96
+ return async (c, next) => {
97
+ if (SAFE_METHODS.has(c.req.method)) {
98
+ await next();
99
+ return;
100
+ }
101
+
102
+ const session = sessionOf(c);
103
+ const header = c.req.header(CSRF_HEADER);
104
+ const cookie = getCookie(c, deps.sessions.names.csrf);
105
+
106
+ if (!header || !cookie || !safeEqual(header, cookie) || !safeEqual(header, session.csrfToken)) {
107
+ throw new ApiError("forbidden", "Invalid or missing CSRF token. Reload the page and retry.");
108
+ }
109
+
110
+ await next();
111
+ };
112
+ }
package/src/index.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @micacms/auth-oidc — the server-only OIDC gate around @micacms/runtime.
3
+ *
4
+ * One factory: hand it the finished configuration and a logger, and it
5
+ * returns the `AuthSurface` the runtime mounts — sign-in
6
+ * routes at `/api/auth`, and the session-plus-CSRF gate in front of the admin
7
+ * API — together with the services the composing app reports on.
8
+ *
9
+ * This package is a dependency of `@micacms/self-hosted` and of nothing else.
10
+ * The local editor must never gain a path to it: that absence is the product
11
+ * boundary that keeps `npx @micacms/editor` free of authentication code.
12
+ */
13
+
14
+ import type { AuthSurface, Logger } from "@micacms/runtime";
15
+ import { createRateLimiter, type RateLimiter } from "@micacms/runtime";
16
+
17
+ import { createAdmissionPolicy, type AdmissionPolicy } from "./admission.js";
18
+ import type { AuthConfig } from "./config.js";
19
+ import { csrfProtection, requireAccess } from "./gate.js";
20
+ import { createOidcService, type OidcService } from "./oidc.js";
21
+ import { authRoutes } from "./routes.js";
22
+ import { createSessionService, type SessionService } from "./session.js";
23
+
24
+ export interface OidcAuthDeps {
25
+ config: AuthConfig;
26
+ logger: Logger;
27
+ }
28
+
29
+ export interface OidcAuth {
30
+ /** What `createApp` mounts: routes at /api/auth, the gate before /api/admin. */
31
+ surface: AuthSurface;
32
+ sessions: SessionService;
33
+ admission: AdmissionPolicy;
34
+ oidc: OidcService;
35
+ loginLimiter: RateLimiter;
36
+ }
37
+
38
+ export function createOidcAuth(deps: OidcAuthDeps): OidcAuth {
39
+ const { config, logger } = deps;
40
+
41
+ const sessions = createSessionService(config);
42
+ const admission = createAdmissionPolicy(config);
43
+ const oidc = createOidcService({ config, logger });
44
+ const loginLimiter = createRateLimiter({
45
+ limit: config.MICA_SIGNIN_MAX_ATTEMPTS,
46
+ windowMs: config.MICA_SIGNIN_WINDOW_MS,
47
+ });
48
+
49
+ const gate = { sessions, admission };
50
+
51
+ return {
52
+ surface: {
53
+ routes: authRoutes({ config, sessions, oidc, admission, loginLimiter }),
54
+ gate: [requireAccess(gate), csrfProtection(gate)],
55
+ },
56
+ sessions,
57
+ admission,
58
+ oidc,
59
+ loginLimiter,
60
+ };
61
+ }
62
+
63
+ export { createAdmissionPolicy, type AdmissionPolicy, type AdmissionResult } from "./admission.js";
64
+ export {
65
+ authEnv,
66
+ deriveAuth,
67
+ requireAuthConfig,
68
+ type AuthConfig,
69
+ type AuthEnv,
70
+ } from "./config.js";
71
+ export { csrfProtection, requireAccess, sessionOf } from "./gate.js";
72
+ export { createOidcService, type OidcService } from "./oidc.js";
73
+ export {
74
+ cookieNames,
75
+ createSessionService,
76
+ CSRF_COOKIE,
77
+ CSRF_HEADER,
78
+ SESSION_COOKIE,
79
+ type SessionIdentity,
80
+ type SessionRecord,
81
+ type SessionService,
82
+ } from "./session.js";