@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/src/oidc.ts ADDED
@@ -0,0 +1,314 @@
1
+ /**
2
+ * OpenID Connect.
3
+ *
4
+ * Mica does not authenticate anybody. It asks a provider who you are, and
5
+ * records the answer. Hashing, reset flows, MFA, passkeys and breach response
6
+ * all belong to the identity provider, and none of them are Mica's to get
7
+ * wrong.
8
+ *
9
+ * What is Mica's is the small part here: run the authorization code flow
10
+ * correctly and report what the provider said. Whether that person may edit
11
+ * this site is `services/admission.ts`, and deliberately not a table.
12
+ *
13
+ * Three checks carry the security of the flow, and all three are enforced
14
+ * rather than assumed:
15
+ *
16
+ * - **PKCE** binds the authorization code to this browser, so a code
17
+ * intercepted in a redirect is useless to anyone else.
18
+ * - **state** binds the callback to a request Mica actually started, which
19
+ * is what stops an attacker feeding a victim their own login.
20
+ * - **nonce** binds the ID token to that same request, so a token replayed
21
+ * from elsewhere does not validate.
22
+ *
23
+ * They are carried in a short-lived signed cookie rather than in the database:
24
+ * the transaction lasts one redirect, and a table of them would be a table of
25
+ * garbage to collect.
26
+ */
27
+
28
+ import { createHmac, hkdfSync, randomBytes, timingSafeEqual } from "node:crypto";
29
+
30
+ import * as client from "openid-client";
31
+
32
+ import { ApiError, type Logger } from "@micacms/runtime";
33
+
34
+ import type { AuthConfig } from "./config.js";
35
+
36
+ /**
37
+ * How long a sign-in may take.
38
+ *
39
+ * Long enough to enter a second factor and read an email; short enough that an
40
+ * abandoned transaction cookie is not a lasting liability.
41
+ */
42
+ const TRANSACTION_TTL_MS = 15 * 60 * 1000;
43
+
44
+ export interface OidcTransaction {
45
+ state: string;
46
+ nonce: string;
47
+ verifier: string;
48
+ /** Where to send the browser once the session exists. */
49
+ returnTo: string;
50
+ expiresAt: number;
51
+ }
52
+
53
+ /** What the provider told us about the person signing in. */
54
+ export interface OidcClaims {
55
+ issuer: string;
56
+ subject: string;
57
+ email: string;
58
+ /** The provider's `email_verified`. Absent counts as false, never as true. */
59
+ emailVerified: boolean;
60
+ name: string;
61
+ /** The configured groups claim, or empty when the provider sends none. */
62
+ groups: string[];
63
+ }
64
+
65
+ /* ------------------------------------------------------------------ *
66
+ * The transaction cookie
67
+ * ------------------------------------------------------------------ */
68
+
69
+ // Its own key, derived like the session's: one secret, never used directly for two purposes (#91).
70
+ const transactionKey = (secret: string): Buffer =>
71
+ Buffer.from(hkdfSync("sha256", secret, "", "mica oidc transaction v1", 32));
72
+
73
+ function sign(value: string, secret: string): string {
74
+ return createHmac("sha256", transactionKey(secret)).update(value).digest("base64url");
75
+ }
76
+
77
+ function safeEqual(a: string, b: string): boolean {
78
+ const left = Buffer.from(a);
79
+ const right = Buffer.from(b);
80
+ return left.length === right.length && timingSafeEqual(left, right);
81
+ }
82
+
83
+ export function sealTransaction(transaction: OidcTransaction, secret: string): string {
84
+ const payload = Buffer.from(JSON.stringify(transaction)).toString("base64url");
85
+ return `${payload}.${sign(payload, secret)}`;
86
+ }
87
+
88
+ export function openTransaction(cookie: string, secret: string): OidcTransaction | null {
89
+ const separator = cookie.lastIndexOf(".");
90
+ if (separator < 1) return null;
91
+
92
+ const payload = cookie.slice(0, separator);
93
+ const signature = cookie.slice(separator + 1);
94
+
95
+ if (!safeEqual(signature, sign(payload, secret))) return null;
96
+
97
+ try {
98
+ const transaction = JSON.parse(
99
+ Buffer.from(payload, "base64url").toString("utf8"),
100
+ ) as OidcTransaction;
101
+
102
+ return transaction.expiresAt > Date.now() ? transaction : null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * The address and its `email_verified` flag, always from the same answer, so
110
+ * that a flag cannot vouch for an address it was not sent with (#91). Userinfo
111
+ * fills in an address the ID token lacks, or confirms the token's own address.
112
+ */
113
+ export function emailFrom(
114
+ claims: Record<string, unknown>,
115
+ info?: Record<string, unknown>,
116
+ ): { email: string; emailVerified: boolean } {
117
+ const tokenEmail = typeof claims["email"] === "string" ? claims["email"] : "";
118
+ const infoEmail = typeof info?.["email"] === "string" ? info["email"] : "";
119
+ const infoVerified = info?.["email_verified"] === true;
120
+
121
+ if (tokenEmail) {
122
+ const confirmed = infoEmail === tokenEmail && infoVerified;
123
+ return { email: tokenEmail, emailVerified: claims["email_verified"] === true || confirmed };
124
+ }
125
+ return { email: infoEmail, emailVerified: Boolean(infoEmail) && infoVerified };
126
+ }
127
+
128
+ /* ------------------------------------------------------------------ *
129
+ * The service
130
+ * ------------------------------------------------------------------ */
131
+
132
+ export interface OidcServiceDeps {
133
+ config: AuthConfig;
134
+ logger: Logger;
135
+ }
136
+
137
+ export function createOidcService(deps: OidcServiceDeps) {
138
+ const { config, logger } = deps;
139
+
140
+ const redirectUri = new URL("/api/auth/callback", config.MICA_PUBLIC_URL).toString();
141
+
142
+ /**
143
+ * Discovery, done once and remembered.
144
+ *
145
+ * Lazy rather than at startup: an identity provider that is briefly
146
+ * unreachable should delay the first sign-in, not stop Mica from booting and
147
+ * taking its health check down with it.
148
+ */
149
+ let discovered: Promise<client.Configuration> | null = null;
150
+
151
+ function configuration(): Promise<client.Configuration> {
152
+ discovered ??= client
153
+ .discovery(
154
+ new URL(config.MICA_OIDC_ISSUER!),
155
+ config.MICA_OIDC_CLIENT_ID!,
156
+ config.MICA_OIDC_CLIENT_SECRET,
157
+ undefined,
158
+ // Only ever for a provider on localhost, and only outside production —
159
+ // the config layer refuses the combination anywhere else.
160
+ config.oidcAllowInsecure ? { execute: [client.allowInsecureRequests] } : undefined,
161
+ )
162
+ .catch((error: unknown) => {
163
+ // Forget the failure so the next attempt retries rather than caching a
164
+ // rejected promise for the life of the process.
165
+ discovered = null;
166
+ logger.error("OIDC discovery failed", {
167
+ issuer: config.MICA_OIDC_ISSUER,
168
+ error: String(error),
169
+ });
170
+ throw new ApiError(
171
+ "service_unavailable",
172
+ "The identity provider could not be reached. Try again shortly.",
173
+ );
174
+ });
175
+
176
+ return discovered;
177
+ }
178
+
179
+ return {
180
+ /** Where to send a browser to sign in, plus the transaction to remember. */
181
+ async begin(returnTo: string): Promise<{ url: string; transaction: OidcTransaction }> {
182
+ const as = await configuration();
183
+
184
+ const verifier = client.randomPKCECodeVerifier();
185
+ const transaction: OidcTransaction = {
186
+ state: client.randomState(),
187
+ nonce: client.randomNonce(),
188
+ verifier,
189
+ returnTo,
190
+ expiresAt: Date.now() + TRANSACTION_TTL_MS,
191
+ };
192
+
193
+ const url = client.buildAuthorizationUrl(as, {
194
+ redirect_uri: redirectUri,
195
+ scope: config.MICA_OIDC_SCOPE,
196
+ state: transaction.state,
197
+ nonce: transaction.nonce,
198
+ code_challenge: await client.calculatePKCECodeChallenge(verifier),
199
+ code_challenge_method: "S256",
200
+ });
201
+
202
+ return { url: url.toString(), transaction };
203
+ },
204
+
205
+ /**
206
+ * Completes the flow and returns who signed in.
207
+ *
208
+ * `authorizationCodeGrant` verifies the ID token signature, the issuer, the
209
+ * audience, the expiry, the nonce and the PKCE verifier. Doing any of that
210
+ * by hand here would be reimplementing the part of this that is genuinely
211
+ * hard to get right.
212
+ */
213
+ async complete(currentUrl: URL, transaction: OidcTransaction): Promise<OidcClaims> {
214
+ const as = await configuration();
215
+
216
+ let tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers;
217
+ try {
218
+ tokens = await client.authorizationCodeGrant(as, currentUrl, {
219
+ pkceCodeVerifier: transaction.verifier,
220
+ expectedState: transaction.state,
221
+ expectedNonce: transaction.nonce,
222
+ });
223
+ } catch (error) {
224
+ logger.warn("OIDC callback rejected", { error: String(error) });
225
+ throw new ApiError("unauthenticated", "Sign-in could not be completed. Please try again.");
226
+ }
227
+
228
+ const claims = tokens.claims();
229
+ if (!claims?.sub) {
230
+ throw new ApiError("unauthenticated", "The identity provider returned no subject.");
231
+ }
232
+
233
+ // Not every provider puts email, name and groups in the ID token; some
234
+ // keep them at the userinfo endpoint, and some only release groups
235
+ // there. Ask only when something is missing.
236
+ const groupsClaim = config.MICA_OIDC_GROUPS_CLAIM;
237
+
238
+ let { email, emailVerified } = emailFrom(claims);
239
+ let name = typeof claims["name"] === "string" ? claims["name"] : "";
240
+ let groups = readGroups(claims[groupsClaim]);
241
+
242
+ if (!email || !emailVerified || !name || groups.length === 0) {
243
+ try {
244
+ const info = await client.fetchUserInfo(as, tokens.access_token, claims.sub);
245
+ ({ email, emailVerified } = emailFrom(claims, info));
246
+ name ||= typeof info.name === "string" ? info.name : "";
247
+ if (groups.length === 0) groups = readGroups(info[groupsClaim]);
248
+ } catch (error) {
249
+ logger.debug("OIDC userinfo unavailable", { error: String(error) });
250
+ }
251
+ }
252
+
253
+ if (!email) {
254
+ // Mica names people by email — it is who the commits are from. An
255
+ // identity without one cannot be shown to anybody, so it is refused
256
+ // rather than displayed as blank.
257
+ throw new ApiError(
258
+ "unauthenticated",
259
+ "The identity provider did not release an email address. " +
260
+ "Grant Mica the `email` scope.",
261
+ );
262
+ }
263
+
264
+ return {
265
+ issuer: String(claims.iss),
266
+ subject: String(claims.sub),
267
+ email,
268
+ emailVerified,
269
+ name: name || email,
270
+ groups,
271
+ };
272
+ },
273
+
274
+ /**
275
+ * Where to send the browser to end the provider's session too.
276
+ *
277
+ * Null when the provider does not advertise the endpoint: signing out of
278
+ * Mica always works, and signing out of the IdP is a bonus that not every
279
+ * deployment can offer.
280
+ */
281
+ async endSessionUrl(): Promise<string | null> {
282
+ try {
283
+ const as = await configuration();
284
+ return client
285
+ .buildEndSessionUrl(as, {
286
+ post_logout_redirect_uri: new URL("/admin/", config.MICA_PUBLIC_URL).toString(),
287
+ })
288
+ .toString();
289
+ } catch {
290
+ return null;
291
+ }
292
+ },
293
+ };
294
+ }
295
+
296
+ export type OidcService = ReturnType<typeof createOidcService>;
297
+
298
+ /**
299
+ * A groups claim, from a provider that may not send one and is under no
300
+ * obligation to send it as an array.
301
+ *
302
+ * Keycloak sends a list of paths, Entra a list of ids, some deployments a
303
+ * single string, and a misconfigured mapper sends an object. Everything that
304
+ * is not a string in a list is dropped rather than coerced, because a group
305
+ * name Mica invented would be a group name that could match a rule.
306
+ */
307
+ function readGroups(value: unknown): string[] {
308
+ if (typeof value === "string") return value.split(/[,\s]+/).filter(Boolean);
309
+ if (!Array.isArray(value)) return [];
310
+ return value.filter((entry): entry is string => typeof entry === "string");
311
+ }
312
+
313
+ /** A random return path token, for the local mode that has no provider. */
314
+ export const randomToken = (): string => randomBytes(32).toString("base64url");
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Where the browser goes after sign-in: a page of this editor, or the start page.
3
+ *
4
+ * Parsed the way a browser parses a `Location`, so that `/\evil.example`, a
5
+ * tab or a line break cannot turn a path into another host (#87).
6
+ */
7
+ export function safeReturnTo(raw: string | undefined, publicUrl: string): string {
8
+ const fallback = "/admin/";
9
+ if (!raw || !raw.startsWith("/")) return fallback;
10
+
11
+ let url: URL;
12
+ try {
13
+ url = new URL(raw, publicUrl);
14
+ } catch {
15
+ return fallback;
16
+ }
17
+
18
+ if (url.origin !== new URL(publicUrl).origin) return fallback;
19
+ if (url.pathname !== "/admin" && !url.pathname.startsWith("/admin/")) return fallback;
20
+ return `${url.pathname}${url.search}${url.hash}`;
21
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Authentication routes.
3
+ *
4
+ * The authorization code flow, and nothing else. There is no login form here,
5
+ * no password check and no reset: the browser is sent to an identity provider,
6
+ * comes back with a code, and Mica exchanges it for a statement about who the
7
+ * person is.
8
+ *
9
+ * What Mica still decides is whether that person may edit *this site*. A valid
10
+ * token from a correctly configured provider proves an identity and nothing
11
+ * more; `admission.ts` is the gate, and an unknown person hits it.
12
+ */
13
+
14
+ import { Hono } from "hono";
15
+ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
16
+
17
+ import { ApiError, clientIp, type Env, type RateLimiter } from "@micacms/runtime";
18
+
19
+ import type { AdmissionPolicy } from "./admission.js";
20
+ import type { AuthConfig } from "./config.js";
21
+ import { requireAccess, sessionOf, type GateDeps } from "./gate.js";
22
+ import { safeReturnTo } from "./return-to.js";
23
+ import {
24
+ establishSession,
25
+ type SessionIdentity,
26
+ type SessionService,
27
+ } from "./session.js";
28
+ import {
29
+ openTransaction,
30
+ sealTransaction,
31
+ type OidcClaims,
32
+ type OidcService,
33
+ } from "./oidc.js";
34
+
35
+ const identityOf = (claims: OidcClaims): SessionIdentity => ({
36
+ issuer: claims.issuer,
37
+ subject: claims.subject,
38
+ email: claims.email,
39
+ emailVerified: claims.emailVerified,
40
+ name: claims.name,
41
+ groups: claims.groups,
42
+ });
43
+
44
+ /** Why a sign-in came back to the login view. The SPA has the words for each. */
45
+ export type SignInRefusal = "rate_limited" | "expired" | "failed" | "not_admitted" | "too_large";
46
+
47
+ export interface AuthRoutesDeps {
48
+ config: AuthConfig;
49
+ sessions: SessionService;
50
+ oidc: OidcService;
51
+ admission: AdmissionPolicy;
52
+ loginLimiter: RateLimiter;
53
+ }
54
+
55
+ export function authRoutes(deps: AuthRoutesDeps): Hono<Env> {
56
+ const app = new Hono<Env>();
57
+ const { config, sessions, oidc, admission } = deps;
58
+ const gate: GateDeps = deps;
59
+
60
+ /**
61
+ * What the sign-in screen needs to know before it draws anything.
62
+ *
63
+ * Public: it reveals only that this deployment authenticates and which
64
+ * provider it uses, which the redirect would reveal a moment later anyway.
65
+ */
66
+ app.get("/config", (c) =>
67
+ c.json({
68
+ authRequired: true,
69
+ provider: config.MICA_OIDC_ISSUER ? new URL(config.MICA_OIDC_ISSUER).hostname : null,
70
+ }),
71
+ );
72
+
73
+ /** Starts the flow. A redirect, not a form post. */
74
+ app.get("/login", async (c) => {
75
+ const returnTo = safeReturnTo(c.req.query("returnTo"), config.MICA_PUBLIC_URL);
76
+
77
+ // Rate limited on the way out and on the way back (in /callback): starting a
78
+ // flow is cheap for a client and costs Mica a discovery request.
79
+ const limit = deps.loginLimiter.check(`oidc:${clientIp(c)}`);
80
+ if (!limit.allowed) {
81
+ throw new ApiError(
82
+ "rate_limited",
83
+ `Too many sign-in attempts. Try again in ${Math.ceil(limit.retryAfter / 60)} minutes.`,
84
+ { headers: { "retry-after": String(limit.retryAfter) } },
85
+ );
86
+ }
87
+
88
+ const { url, transaction } = await oidc.begin(returnTo);
89
+
90
+ setCookie(c, sessions.names.transaction, sealTransaction(transaction, config.sessionSecret), {
91
+ path: "/api/auth",
92
+ httpOnly: true,
93
+ secure: config.cookieSecure,
94
+ // Lax, not Strict: the provider redirects the browser back here from
95
+ // another origin, and Strict would withhold the cookie exactly then.
96
+ sameSite: "Lax",
97
+ maxAge: 15 * 60,
98
+ });
99
+
100
+ return c.redirect(url);
101
+ });
102
+
103
+ /**
104
+ * Where the provider sends the browser back.
105
+ *
106
+ * A browser is following a redirect here, not making an XHR, so a failure
107
+ * must not be a JSON error envelope — that would put a wall of braces on the
108
+ * screen. Everything that can go wrong goes back to the sign-in page with a
109
+ * message the person can act on.
110
+ */
111
+ app.get("/callback", async (c) => {
112
+ const logger = c.get("logger");
113
+
114
+ // A code, not a sentence: the URL lands in browser history and proxy logs,
115
+ // so it must not carry the person's address (#150). The login view words it.
116
+ const refuse = (code: SignInRefusal) => c.redirect(`/admin/login?error=${code}`);
117
+
118
+ // A replayed transaction cookie would call the token endpoint each time.
119
+ // Its own key, so that one sign-in does not count twice against the start.
120
+ if (!deps.loginLimiter.check(`oidc-callback:${clientIp(c)}`).allowed) {
121
+ return refuse("rate_limited");
122
+ }
123
+
124
+ const sealed = getCookie(c, sessions.names.transaction);
125
+ deleteCookie(c, sessions.names.transaction, { path: "/api/auth", secure: config.cookieSecure });
126
+
127
+ const transaction = sealed ? openTransaction(sealed, config.sessionSecret) : null;
128
+ if (!transaction) {
129
+ // Expired, tampered with, or a callback nobody asked for.
130
+ return refuse("expired");
131
+ }
132
+
133
+ let claims: OidcClaims;
134
+ try {
135
+ claims = await oidc.complete(new URL(c.req.url), transaction);
136
+ } catch (error) {
137
+ logger.warn("Sign-in could not be completed", {
138
+ error: error instanceof Error ? error.message : String(error),
139
+ });
140
+ return refuse("failed");
141
+ }
142
+
143
+ const identity = identityOf(claims);
144
+
145
+ /**
146
+ * Authentication succeeded; admission is a separate question.
147
+ *
148
+ * A provider may vouch for an entire company. The admission rules are what
149
+ * say this particular person may edit this particular site, and their
150
+ * refusal is the expected answer for everybody who was not meant to be
151
+ * here — so it is a plain message, not an error.
152
+ */
153
+ const verdict = admission.admits(identity);
154
+ if (!verdict.admitted) {
155
+ logger.warn("Authenticated but not admitted", {
156
+ subject: identity.subject,
157
+ reason: verdict.reason,
158
+ });
159
+ return refuse("not_admitted");
160
+ }
161
+
162
+ try {
163
+ establishSession(c, sessions, identity);
164
+ } catch (error) {
165
+ logger.error("Could not start a session", { subject: identity.subject, error });
166
+ return refuse("too_large");
167
+ }
168
+ logger.info("Signed in", { subject: identity.subject, issuer: identity.issuer });
169
+
170
+ return c.redirect(transaction.returnTo);
171
+ });
172
+
173
+ // There is no server-side session to end; clearing the cookies is sign-out.
174
+ app.post("/logout", async (c) => {
175
+ deleteCookie(c, sessions.names.session, sessions.clearedCookieOptions());
176
+ deleteCookie(c, sessions.names.csrf, sessions.clearedCookieOptions());
177
+
178
+ // Offered, not followed: ending the provider's session is the provider's
179
+ // decision to make, and the SPA sends the browser there if it is there.
180
+ return c.json({ ok: true, endSession: await oidc.endSessionUrl() });
181
+ });
182
+
183
+ /** The session this request is in, and who it belongs to. */
184
+ app.get("/me", requireAccess(gate), (c) => {
185
+ const session = sessionOf(c);
186
+ const { identity } = c.get("access");
187
+
188
+ return c.json({
189
+ identity,
190
+ csrfToken: session.csrfToken,
191
+ expiresAt: session.expiresAt,
192
+ });
193
+ });
194
+
195
+ return app;
196
+ }