@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.
- package/README.md +3 -1
- package/dist/admission.d.ts +2 -2
- package/dist/admission.js +2 -2
- package/dist/admission.js.map +1 -1
- package/dist/config.d.ts +19 -19
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +13 -13
- package/dist/config.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/session.d.ts +2 -2
- package/dist/session.js +4 -4
- package/dist/session.js.map +1 -1
- package/package.json +13 -5
- package/src/admission.ts +132 -0
- package/src/config.ts +221 -0
- package/src/gate.ts +112 -0
- package/src/index.ts +82 -0
- package/src/oidc.ts +314 -0
- package/src/return-to.ts +21 -0
- package/src/routes.ts +196 -0
- package/src/session.ts +300 -0
package/src/session.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session management.
|
|
3
|
+
*
|
|
4
|
+
* The session is the cookie: who the provider said this is, a CSRF token and
|
|
5
|
+
* an expiry, encrypted with a key derived from MICA_SESSION_SECRET. There is no
|
|
6
|
+
* session table, so Mica stores nothing about people at all.
|
|
7
|
+
*
|
|
8
|
+
* - AES-256-GCM, so the cookie can be neither read nor altered. Encrypted and
|
|
9
|
+
* not only signed, because it carries an email address and groups.
|
|
10
|
+
* - A fresh random IV per cookie, and a new cookie at every sign-in, so a
|
|
11
|
+
* client-supplied value is never adopted (session fixation).
|
|
12
|
+
* - The idle window slides: an active session is re-issued every few
|
|
13
|
+
* minutes with a later expiry, capped by the absolute lifetime.
|
|
14
|
+
*
|
|
15
|
+
* What this gives up is server-side revocation of one cookie. Sign-out clears
|
|
16
|
+
* it in the browser; a copy taken before then stays valid until it expires.
|
|
17
|
+
* Rotating MICA_SESSION_SECRET ends every session at once. The admission rules are
|
|
18
|
+
* still re-read on every request — see gate.ts and docs/decisions.md.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes, timingSafeEqual } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
import type { Context } from "hono";
|
|
24
|
+
import { setCookie } from "hono/cookie";
|
|
25
|
+
|
|
26
|
+
import { randomToken, type Env } from "@micacms/runtime";
|
|
27
|
+
|
|
28
|
+
import type { AuthConfig } from "./config.js";
|
|
29
|
+
|
|
30
|
+
export const SESSION_COOKIE = "mica_session";
|
|
31
|
+
export const CSRF_COOKIE = "mica_csrf";
|
|
32
|
+
export const CSRF_HEADER = "x-csrf-token";
|
|
33
|
+
export const TRANSACTION_COOKIE = "mica_oidc";
|
|
34
|
+
|
|
35
|
+
export interface CookieNames {
|
|
36
|
+
session: string;
|
|
37
|
+
csrf: string;
|
|
38
|
+
transaction: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* With https, the names carry a prefix the browser enforces: `__Host-` means
|
|
43
|
+
* Secure, path `/` and no Domain, so a sibling subdomain cannot plant or
|
|
44
|
+
* overwrite the cookie. The transaction cookie is scoped to `/api/auth`, so it
|
|
45
|
+
* can only be `__Secure-`. Over plain HTTP the cookies are not Secure, and a
|
|
46
|
+
* browser would refuse a prefixed name.
|
|
47
|
+
*/
|
|
48
|
+
export function cookieNames(secure: boolean): CookieNames {
|
|
49
|
+
return secure
|
|
50
|
+
? {
|
|
51
|
+
session: `__Host-${SESSION_COOKIE}`,
|
|
52
|
+
csrf: `__Host-${CSRF_COOKIE}`,
|
|
53
|
+
transaction: `__Secure-${TRANSACTION_COOKIE}`,
|
|
54
|
+
}
|
|
55
|
+
: { session: SESSION_COOKIE, csrf: CSRF_COOKIE, transaction: TRANSACTION_COOKIE };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** How often an active session is re-issued with a later idle expiry. */
|
|
59
|
+
const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
|
60
|
+
|
|
61
|
+
/** Browsers drop cookies over 4096 bytes; refuse rather than sign in to nothing. */
|
|
62
|
+
const MAX_COOKIE_BYTES = 4000;
|
|
63
|
+
|
|
64
|
+
const IV_BYTES = 12;
|
|
65
|
+
const TAG_BYTES = 16;
|
|
66
|
+
|
|
67
|
+
/** What the identity provider stated about the person holding this session. */
|
|
68
|
+
export interface SessionIdentity {
|
|
69
|
+
issuer: string;
|
|
70
|
+
/** Opaque and stable at that issuer. */
|
|
71
|
+
subject: string;
|
|
72
|
+
email: string;
|
|
73
|
+
/** The provider's own `email_verified`. Absent counts as false. */
|
|
74
|
+
emailVerified: boolean;
|
|
75
|
+
name: string;
|
|
76
|
+
/** The groups claim, reduced at sign-in to the groups the rules name. */
|
|
77
|
+
groups: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SessionRecord {
|
|
81
|
+
identity: SessionIdentity;
|
|
82
|
+
csrfToken: string;
|
|
83
|
+
createdAt: string;
|
|
84
|
+
/** When the session was last issued; drives the sliding idle window. */
|
|
85
|
+
refreshedAt: string;
|
|
86
|
+
expiresAt: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SessionService {
|
|
90
|
+
/** The cookie names, which depend on whether the cookies are Secure. */
|
|
91
|
+
readonly names: CookieNames;
|
|
92
|
+
/** Seals a new session. Throws when the identity cannot fit in a cookie. */
|
|
93
|
+
create(identity: SessionIdentity): { record: SessionRecord; cookie: string };
|
|
94
|
+
/** The live session in a cookie, or null for a missing, forged or expired one. */
|
|
95
|
+
resolve(cookieValue: string | undefined): SessionRecord | null;
|
|
96
|
+
/** A later-expiring copy of an active session, or null while it is fresh. */
|
|
97
|
+
refresh(record: SessionRecord): { record: SessionRecord; cookie: string } | null;
|
|
98
|
+
sessionCookieOptions(): CookieOptions;
|
|
99
|
+
csrfCookieOptions(): CookieOptions;
|
|
100
|
+
clearedCookieOptions(): CookieOptions;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface CookieOptions {
|
|
104
|
+
path: string;
|
|
105
|
+
httpOnly: boolean;
|
|
106
|
+
secure: boolean;
|
|
107
|
+
sameSite: "Lax" | "Strict" | "None";
|
|
108
|
+
maxAge: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Constant-time string comparison that tolerates differing lengths. */
|
|
112
|
+
export function safeEqual(a: string, b: string): boolean {
|
|
113
|
+
const left = Buffer.from(a);
|
|
114
|
+
const right = Buffer.from(b);
|
|
115
|
+
if (left.length !== right.length) {
|
|
116
|
+
// Still compare, so the branch does not leak the length difference.
|
|
117
|
+
timingSafeEqual(left, left);
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return timingSafeEqual(left, right);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Keycloak sends `/mica-editors` where the rule says `mica-editors`; both
|
|
125
|
+
* spellings are the same group — see admission.ts.
|
|
126
|
+
*/
|
|
127
|
+
const groupName = (group: string): string => group.replace(/^\//, "");
|
|
128
|
+
|
|
129
|
+
/** Defensive read of a decrypted payload: it was written by us, but checked anyway. */
|
|
130
|
+
function parseRecord(json: string): SessionRecord | null {
|
|
131
|
+
try {
|
|
132
|
+
const value = JSON.parse(json) as Partial<SessionRecord>;
|
|
133
|
+
const identity = value.identity;
|
|
134
|
+
if (
|
|
135
|
+
!identity ||
|
|
136
|
+
typeof identity.issuer !== "string" ||
|
|
137
|
+
typeof identity.subject !== "string" ||
|
|
138
|
+
typeof value.csrfToken !== "string" ||
|
|
139
|
+
typeof value.createdAt !== "string" ||
|
|
140
|
+
typeof value.refreshedAt !== "string" ||
|
|
141
|
+
typeof value.expiresAt !== "string"
|
|
142
|
+
) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
identity: {
|
|
147
|
+
issuer: identity.issuer,
|
|
148
|
+
subject: identity.subject,
|
|
149
|
+
email: typeof identity.email === "string" ? identity.email : "",
|
|
150
|
+
emailVerified: identity.emailVerified === true,
|
|
151
|
+
name: typeof identity.name === "string" ? identity.name : "",
|
|
152
|
+
groups: Array.isArray(identity.groups)
|
|
153
|
+
? identity.groups.filter((group): group is string => typeof group === "string")
|
|
154
|
+
: [],
|
|
155
|
+
},
|
|
156
|
+
csrfToken: value.csrfToken,
|
|
157
|
+
createdAt: value.createdAt,
|
|
158
|
+
refreshedAt: value.refreshedAt,
|
|
159
|
+
expiresAt: value.expiresAt,
|
|
160
|
+
};
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function createSessionService(config: AuthConfig): SessionService {
|
|
167
|
+
const ttlMs = config.MICA_SESSION_TTL_HOURS * 3600_000;
|
|
168
|
+
const idleMs = config.MICA_SESSION_IDLE_HOURS * 3600_000;
|
|
169
|
+
|
|
170
|
+
const key = Buffer.from(
|
|
171
|
+
hkdfSync("sha256", config.sessionSecret, "", "mica session cookie v1", 32),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// Only the groups a rule names are kept: an Entra token can carry hundreds,
|
|
175
|
+
// and a cookie holds 4 KB. A group added to the rules later counts from the
|
|
176
|
+
// next sign-in.
|
|
177
|
+
const admitted = new Set(config.admitGroups.map(groupName));
|
|
178
|
+
|
|
179
|
+
/** Expiry is the earlier of the idle window and the absolute lifetime. */
|
|
180
|
+
const nextExpiry = (createdAt: number, now: number): string =>
|
|
181
|
+
new Date(Math.min(now + idleMs, createdAt + ttlMs)).toISOString();
|
|
182
|
+
|
|
183
|
+
const seal = (record: SessionRecord): string => {
|
|
184
|
+
const iv = randomBytes(IV_BYTES);
|
|
185
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
186
|
+
const body = Buffer.concat([cipher.update(JSON.stringify(record), "utf8"), cipher.final()]);
|
|
187
|
+
const cookie = Buffer.concat([iv, body, cipher.getAuthTag()]).toString("base64url");
|
|
188
|
+
|
|
189
|
+
if (cookie.length > MAX_COOKIE_BYTES) {
|
|
190
|
+
throw new Error("The session does not fit in a cookie; the identity is too large.");
|
|
191
|
+
}
|
|
192
|
+
return cookie;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const open = (cookieValue: string): SessionRecord | null => {
|
|
196
|
+
const raw = Buffer.from(cookieValue, "base64url");
|
|
197
|
+
if (raw.length <= IV_BYTES + TAG_BYTES) return null;
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const decipher = createDecipheriv("aes-256-gcm", key, raw.subarray(0, IV_BYTES));
|
|
201
|
+
decipher.setAuthTag(raw.subarray(raw.length - TAG_BYTES));
|
|
202
|
+
const json = Buffer.concat([
|
|
203
|
+
decipher.update(raw.subarray(IV_BYTES, raw.length - TAG_BYTES)),
|
|
204
|
+
decipher.final(),
|
|
205
|
+
]).toString("utf8");
|
|
206
|
+
return parseRecord(json);
|
|
207
|
+
} catch {
|
|
208
|
+
// Forged, truncated, or sealed with a secret that has since rotated.
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
names: cookieNames(config.cookieSecure),
|
|
215
|
+
|
|
216
|
+
create(identity) {
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
const record: SessionRecord = {
|
|
219
|
+
identity: {
|
|
220
|
+
...identity,
|
|
221
|
+
groups: identity.groups.filter((group) => admitted.has(groupName(group))),
|
|
222
|
+
},
|
|
223
|
+
csrfToken: randomToken(32),
|
|
224
|
+
createdAt: new Date(now).toISOString(),
|
|
225
|
+
refreshedAt: new Date(now).toISOString(),
|
|
226
|
+
expiresAt: nextExpiry(now, now),
|
|
227
|
+
};
|
|
228
|
+
return { record, cookie: seal(record) };
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
resolve(cookieValue) {
|
|
232
|
+
if (!cookieValue) return null;
|
|
233
|
+
const record = open(cookieValue);
|
|
234
|
+
if (!record || Date.parse(record.expiresAt) <= Date.now()) return null;
|
|
235
|
+
return record;
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
refresh(record) {
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
if (now - Date.parse(record.refreshedAt) < REFRESH_INTERVAL_MS) return null;
|
|
241
|
+
|
|
242
|
+
const next: SessionRecord = {
|
|
243
|
+
...record,
|
|
244
|
+
refreshedAt: new Date(now).toISOString(),
|
|
245
|
+
expiresAt: nextExpiry(Date.parse(record.createdAt), now),
|
|
246
|
+
};
|
|
247
|
+
return { record: next, cookie: seal(next) };
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
sessionCookieOptions() {
|
|
251
|
+
return {
|
|
252
|
+
path: "/",
|
|
253
|
+
httpOnly: true,
|
|
254
|
+
secure: config.cookieSecure,
|
|
255
|
+
// Lax keeps the cookie on top-level navigations back to the admin
|
|
256
|
+
// while blocking it on cross-site form posts.
|
|
257
|
+
sameSite: "Lax",
|
|
258
|
+
maxAge: Math.floor(ttlMs / 1000),
|
|
259
|
+
};
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
csrfCookieOptions() {
|
|
263
|
+
return {
|
|
264
|
+
path: "/",
|
|
265
|
+
// Readable by the SPA on purpose: it echoes the value in a header,
|
|
266
|
+
// which is what makes the double-submit check work.
|
|
267
|
+
httpOnly: false,
|
|
268
|
+
secure: config.cookieSecure,
|
|
269
|
+
sameSite: "Lax",
|
|
270
|
+
maxAge: Math.floor(ttlMs / 1000),
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
clearedCookieOptions() {
|
|
275
|
+
return {
|
|
276
|
+
path: "/",
|
|
277
|
+
httpOnly: true,
|
|
278
|
+
secure: config.cookieSecure,
|
|
279
|
+
sameSite: "Lax",
|
|
280
|
+
maxAge: 0,
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* ------------------------------------------------------------------ *
|
|
287
|
+
* Starting one
|
|
288
|
+
* ------------------------------------------------------------------ */
|
|
289
|
+
|
|
290
|
+
/** Issues the session cookies for a browser that has just been identified. */
|
|
291
|
+
export function establishSession(
|
|
292
|
+
c: Context<Env>,
|
|
293
|
+
sessions: SessionService,
|
|
294
|
+
identity: SessionIdentity,
|
|
295
|
+
): void {
|
|
296
|
+
const { record, cookie } = sessions.create(identity);
|
|
297
|
+
|
|
298
|
+
setCookie(c, sessions.names.session, cookie, sessions.sessionCookieOptions());
|
|
299
|
+
setCookie(c, sessions.names.csrf, record.csrfToken, sessions.csrfCookieOptions());
|
|
300
|
+
}
|