@micacms/auth-oidc 0.2.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.
package/dist/index.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @micacms/auth-oidc — the server-only OIDC gate around @micacms/runtime.
3
+ *
4
+ * One factory: hand it the finished configuration, an open database and a
5
+ * logger, and it 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
+ import { createRateLimiter } from "@micacms/runtime";
14
+ import { createAdmissionPolicy } from "./admission.js";
15
+ import { csrfProtection, requireAccess } from "./gate.js";
16
+ import { createOidcService } from "./oidc.js";
17
+ import { authRoutes } from "./routes.js";
18
+ import { createSessionService } from "./session.js";
19
+ import { createSessionRepository } from "./sessions-repository.js";
20
+ export function createOidcAuth(deps) {
21
+ const { config, logger } = deps;
22
+ const sessionsRepo = createSessionRepository(deps.db);
23
+ const sessions = createSessionService(sessionsRepo, config);
24
+ const admission = createAdmissionPolicy(config);
25
+ const oidc = createOidcService({ config, logger });
26
+ const loginLimiter = createRateLimiter({
27
+ limit: config.LOGIN_MAX_ATTEMPTS,
28
+ windowMs: config.LOGIN_WINDOW_MS,
29
+ });
30
+ const gate = { sessions, sessionsRepo, admission };
31
+ return {
32
+ surface: {
33
+ routes: authRoutes({ config, sessions, sessionsRepo, oidc, admission, loginLimiter }),
34
+ gate: [requireAccess(gate), csrfProtection()],
35
+ },
36
+ sessions,
37
+ sessionsRepo,
38
+ admission,
39
+ oidc,
40
+ loginLimiter,
41
+ };
42
+ }
43
+ export { createAdmissionPolicy } from "./admission.js";
44
+ export { authEnv, deriveAuth, requireAuthConfig, } from "./config.js";
45
+ export { csrfProtection, requireAccess, sessionOf } from "./gate.js";
46
+ export { createOidcService } from "./oidc.js";
47
+ export { createSessionService, CSRF_COOKIE, CSRF_HEADER, SESSION_COOKIE, } from "./session.js";
48
+ export { createSessionRepository, } from "./sessions-repository.js";
49
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAE,iBAAiB,EAAoB,MAAM,kBAAkB,CAAC;AAEvE,OAAO,EAAE,qBAAqB,EAAwB,MAAM,gBAAgB,CAAC;AAE7E,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AAC1D,OAAO,EAAE,iBAAiB,EAAoB,MAAM,WAAW,CAAC;AAChE,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,oBAAoB,EAAuB,MAAM,cAAc,CAAC;AACzE,OAAO,EAAE,uBAAuB,EAA0B,MAAM,0BAA0B,CAAC;AAmB3F,MAAM,UAAU,cAAc,CAAC,IAAkB;IAC/C,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAEhC,MAAM,YAAY,GAAG,uBAAuB,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACtD,MAAM,QAAQ,GAAG,oBAAoB,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC;IAC5D,MAAM,SAAS,GAAG,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAChD,MAAM,IAAI,GAAG,iBAAiB,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACnD,MAAM,YAAY,GAAG,iBAAiB,CAAC;QACrC,KAAK,EAAE,MAAM,CAAC,kBAAkB;QAChC,QAAQ,EAAE,MAAM,CAAC,eAAe;KACjC,CAAC,CAAC;IAEH,MAAM,IAAI,GAAG,EAAE,QAAQ,EAAE,YAAY,EAAE,SAAS,EAAE,CAAC;IAEnD,OAAO;QACL,OAAO,EAAE;YACP,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,YAAY,EAAE,IAAI,EAAE,SAAS,EAAE,YAAY,EAAE,CAAC;YACrF,IAAI,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,cAAc,EAAE,CAAC;SAC9C;QACD,QAAQ;QACR,YAAY;QACZ,SAAS;QACT,IAAI;QACJ,YAAY;KACb,CAAC;AACJ,CAAC;AAED,OAAO,EAAE,qBAAqB,EAA8C,MAAM,gBAAgB,CAAC;AACnG,OAAO,EACL,OAAO,EACP,UAAU,EACV,iBAAiB,GAGlB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACrE,OAAO,EAAE,iBAAiB,EAAoB,MAAM,WAAW,CAAC;AAChE,OAAO,EACL,oBAAoB,EACpB,WAAW,EACX,WAAW,EACX,cAAc,GAEf,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,uBAAuB,GAIxB,MAAM,0BAA0B,CAAC"}
package/dist/oidc.d.ts ADDED
@@ -0,0 +1,83 @@
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
+ import { type Logger } from "@micacms/runtime";
28
+ import type { AuthConfig } from "./config.js";
29
+ /** Carries state, PKCE verifier and nonce across the redirect to the provider. */
30
+ export declare const TRANSACTION_COOKIE = "mica_oidc";
31
+ export interface OidcTransaction {
32
+ state: string;
33
+ nonce: string;
34
+ verifier: string;
35
+ /** Where to send the browser once the session exists. */
36
+ returnTo: string;
37
+ expiresAt: number;
38
+ }
39
+ /** What the provider told us about the person signing in. */
40
+ export interface OidcClaims {
41
+ issuer: string;
42
+ subject: string;
43
+ email: string;
44
+ /** The provider's `email_verified`. Absent counts as false, never as true. */
45
+ emailVerified: boolean;
46
+ name: string;
47
+ /** The configured groups claim, or empty when the provider sends none. */
48
+ groups: string[];
49
+ }
50
+ export declare function sealTransaction(transaction: OidcTransaction, secret: string): string;
51
+ export declare function openTransaction(cookie: string, secret: string): OidcTransaction | null;
52
+ export interface OidcServiceDeps {
53
+ config: AuthConfig;
54
+ logger: Logger;
55
+ }
56
+ export declare function createOidcService(deps: OidcServiceDeps): {
57
+ /** Where to send a browser to sign in, plus the transaction to remember. */
58
+ begin(returnTo: string): Promise<{
59
+ url: string;
60
+ transaction: OidcTransaction;
61
+ }>;
62
+ /**
63
+ * Completes the flow and returns who signed in.
64
+ *
65
+ * `authorizationCodeGrant` verifies the ID token signature, the issuer, the
66
+ * audience, the expiry, the nonce and the PKCE verifier. Doing any of that
67
+ * by hand here would be reimplementing the part of this that is genuinely
68
+ * hard to get right.
69
+ */
70
+ complete(currentUrl: URL, transaction: OidcTransaction): Promise<OidcClaims>;
71
+ /**
72
+ * Where to send the browser to end the provider's session too.
73
+ *
74
+ * Null when the provider does not advertise the endpoint: signing out of
75
+ * Mica always works, and signing out of the IdP is a bonus that not every
76
+ * deployment can offer.
77
+ */
78
+ endSessionUrl(): Promise<string | null>;
79
+ };
80
+ export type OidcService = ReturnType<typeof createOidcService>;
81
+ /** A random return path token, for the local mode that has no provider. */
82
+ export declare const randomToken: () => string;
83
+ //# sourceMappingURL=oidc.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oidc.d.ts","sourceRoot":"","sources":["../src/oidc.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAMH,OAAO,EAAY,KAAK,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAEzD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,cAAc,CAAC;AAU9C,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,yDAAyD;IACzD,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,6DAA6D;AAC7D,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,8EAA8E;IAC9E,aAAa,EAAE,OAAO,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,0EAA0E;IAC1E,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAgBD,wBAAgB,eAAe,CAAC,WAAW,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAGpF;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI,CAkBtF;AAMD,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,UAAU,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,eAAe;IA2CnD,4EAA4E;oBACtD,MAAM,GAAG,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,eAAe,CAAA;KAAE,CAAC;IAwBrF;;;;;;;OAOG;yBACwB,GAAG,eAAe,eAAe,GAAG,OAAO,CAAC,UAAU,CAAC;IA+DlF;;;;;;OAMG;qBACoB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;EAahD;AAED,MAAM,MAAM,WAAW,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAiB/D,2EAA2E;AAC3E,eAAO,MAAM,WAAW,QAAO,MAA+C,CAAC"}
package/dist/oidc.js ADDED
@@ -0,0 +1,224 @@
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
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
28
+ import * as client from "openid-client";
29
+ import { ApiError } from "@micacms/runtime";
30
+ /** Carries state, PKCE verifier and nonce across the redirect to the provider. */
31
+ export const TRANSACTION_COOKIE = "mica_oidc";
32
+ /**
33
+ * How long a sign-in may take.
34
+ *
35
+ * Long enough to enter a second factor and read an email; short enough that an
36
+ * abandoned transaction cookie is not a lasting liability.
37
+ */
38
+ const TRANSACTION_TTL_MS = 15 * 60 * 1000;
39
+ /* ------------------------------------------------------------------ *
40
+ * The transaction cookie
41
+ * ------------------------------------------------------------------ */
42
+ function sign(value, secret) {
43
+ return createHmac("sha256", secret).update(value).digest("base64url");
44
+ }
45
+ function safeEqual(a, b) {
46
+ const left = Buffer.from(a);
47
+ const right = Buffer.from(b);
48
+ return left.length === right.length && timingSafeEqual(left, right);
49
+ }
50
+ export function sealTransaction(transaction, secret) {
51
+ const payload = Buffer.from(JSON.stringify(transaction)).toString("base64url");
52
+ return `${payload}.${sign(payload, secret)}`;
53
+ }
54
+ export function openTransaction(cookie, secret) {
55
+ const separator = cookie.lastIndexOf(".");
56
+ if (separator < 1)
57
+ return null;
58
+ const payload = cookie.slice(0, separator);
59
+ const signature = cookie.slice(separator + 1);
60
+ if (!safeEqual(signature, sign(payload, secret)))
61
+ return null;
62
+ try {
63
+ const transaction = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
64
+ return transaction.expiresAt > Date.now() ? transaction : null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ export function createOidcService(deps) {
71
+ const { config, logger } = deps;
72
+ const redirectUri = new URL("/api/auth/callback", config.MICA_PUBLIC_URL).toString();
73
+ /**
74
+ * Discovery, done once and remembered.
75
+ *
76
+ * Lazy rather than at startup: an identity provider that is briefly
77
+ * unreachable should delay the first sign-in, not stop Mica from booting and
78
+ * taking its health check down with it.
79
+ */
80
+ let discovered = null;
81
+ function configuration() {
82
+ discovered ??= client
83
+ .discovery(new URL(config.MICA_OIDC_ISSUER), config.MICA_OIDC_CLIENT_ID, config.MICA_OIDC_CLIENT_SECRET, undefined,
84
+ // Only ever for a provider on localhost, and only outside production —
85
+ // the config layer refuses the combination anywhere else.
86
+ config.oidcAllowInsecure ? { execute: [client.allowInsecureRequests] } : undefined)
87
+ .catch((error) => {
88
+ // Forget the failure so the next attempt retries rather than caching a
89
+ // rejected promise for the life of the process.
90
+ discovered = null;
91
+ logger.error("OIDC discovery failed", {
92
+ issuer: config.MICA_OIDC_ISSUER,
93
+ error: String(error),
94
+ });
95
+ throw new ApiError("service_unavailable", "The identity provider could not be reached. Try again shortly.");
96
+ });
97
+ return discovered;
98
+ }
99
+ return {
100
+ /** Where to send a browser to sign in, plus the transaction to remember. */
101
+ async begin(returnTo) {
102
+ const as = await configuration();
103
+ const verifier = client.randomPKCECodeVerifier();
104
+ const transaction = {
105
+ state: client.randomState(),
106
+ nonce: client.randomNonce(),
107
+ verifier,
108
+ returnTo,
109
+ expiresAt: Date.now() + TRANSACTION_TTL_MS,
110
+ };
111
+ const url = client.buildAuthorizationUrl(as, {
112
+ redirect_uri: redirectUri,
113
+ scope: config.MICA_OIDC_SCOPE,
114
+ state: transaction.state,
115
+ nonce: transaction.nonce,
116
+ code_challenge: await client.calculatePKCECodeChallenge(verifier),
117
+ code_challenge_method: "S256",
118
+ });
119
+ return { url: url.toString(), transaction };
120
+ },
121
+ /**
122
+ * Completes the flow and returns who signed in.
123
+ *
124
+ * `authorizationCodeGrant` verifies the ID token signature, the issuer, the
125
+ * audience, the expiry, the nonce and the PKCE verifier. Doing any of that
126
+ * by hand here would be reimplementing the part of this that is genuinely
127
+ * hard to get right.
128
+ */
129
+ async complete(currentUrl, transaction) {
130
+ const as = await configuration();
131
+ let tokens;
132
+ try {
133
+ tokens = await client.authorizationCodeGrant(as, currentUrl, {
134
+ pkceCodeVerifier: transaction.verifier,
135
+ expectedState: transaction.state,
136
+ expectedNonce: transaction.nonce,
137
+ });
138
+ }
139
+ catch (error) {
140
+ logger.warn("OIDC callback rejected", { error: String(error) });
141
+ throw new ApiError("unauthenticated", "Sign-in could not be completed. Please try again.");
142
+ }
143
+ const claims = tokens.claims();
144
+ if (!claims?.sub) {
145
+ throw new ApiError("unauthenticated", "The identity provider returned no subject.");
146
+ }
147
+ // Not every provider puts email, name and groups in the ID token; some
148
+ // keep them at the userinfo endpoint, and some only release groups
149
+ // there. Ask only when something is missing.
150
+ const groupsClaim = config.MICA_OIDC_GROUPS_CLAIM;
151
+ let email = typeof claims["email"] === "string" ? claims["email"] : "";
152
+ let name = typeof claims["name"] === "string" ? claims["name"] : "";
153
+ let emailVerified = claims["email_verified"] === true;
154
+ let groups = readGroups(claims[groupsClaim]);
155
+ if (!email || !name || groups.length === 0) {
156
+ try {
157
+ const info = await client.fetchUserInfo(as, tokens.access_token, claims.sub);
158
+ email ||= typeof info.email === "string" ? info.email : "";
159
+ name ||= typeof info.name === "string" ? info.name : "";
160
+ emailVerified ||= info["email_verified"] === true;
161
+ if (groups.length === 0)
162
+ groups = readGroups(info[groupsClaim]);
163
+ }
164
+ catch (error) {
165
+ logger.debug("OIDC userinfo unavailable", { error: String(error) });
166
+ }
167
+ }
168
+ if (!email) {
169
+ // Mica names people by email — it is who the commits are from. An
170
+ // identity without one cannot be shown to anybody, so it is refused
171
+ // rather than displayed as blank.
172
+ throw new ApiError("unauthenticated", "The identity provider did not release an email address. " +
173
+ "Grant Mica the `email` scope.");
174
+ }
175
+ return {
176
+ issuer: String(claims.iss),
177
+ subject: String(claims.sub),
178
+ email,
179
+ emailVerified,
180
+ name: name || email,
181
+ groups,
182
+ };
183
+ },
184
+ /**
185
+ * Where to send the browser to end the provider's session too.
186
+ *
187
+ * Null when the provider does not advertise the endpoint: signing out of
188
+ * Mica always works, and signing out of the IdP is a bonus that not every
189
+ * deployment can offer.
190
+ */
191
+ async endSessionUrl() {
192
+ try {
193
+ const as = await configuration();
194
+ return client
195
+ .buildEndSessionUrl(as, {
196
+ post_logout_redirect_uri: new URL("/admin/", config.MICA_PUBLIC_URL).toString(),
197
+ })
198
+ .toString();
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ },
204
+ };
205
+ }
206
+ /**
207
+ * A groups claim, from a provider that may not send one and is under no
208
+ * obligation to send it as an array.
209
+ *
210
+ * Keycloak sends a list of paths, Entra a list of ids, some deployments a
211
+ * single string, and a misconfigured mapper sends an object. Everything that
212
+ * is not a string in a list is dropped rather than coerced, because a group
213
+ * name Mica invented would be a group name that could match a rule.
214
+ */
215
+ function readGroups(value) {
216
+ if (typeof value === "string")
217
+ return value.split(/[,\s]+/).filter(Boolean);
218
+ if (!Array.isArray(value))
219
+ return [];
220
+ return value.filter((entry) => typeof entry === "string");
221
+ }
222
+ /** A random return path token, for the local mode that has no provider. */
223
+ export const randomToken = () => randomBytes(32).toString("base64url");
224
+ //# sourceMappingURL=oidc.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oidc.js","sourceRoot":"","sources":["../src/oidc.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,29 @@
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
+ import { Hono } from "hono";
14
+ import { type Env, type RateLimiter } from "@micacms/runtime";
15
+ import type { AdmissionPolicy } from "./admission.js";
16
+ import type { AuthConfig } from "./config.js";
17
+ import { type SessionService } from "./session.js";
18
+ import type { SessionRepository } from "./sessions-repository.js";
19
+ import { type OidcService } from "./oidc.js";
20
+ export interface AuthRoutesDeps {
21
+ config: AuthConfig;
22
+ sessions: SessionService;
23
+ sessionsRepo: SessionRepository;
24
+ oidc: OidcService;
25
+ admission: AdmissionPolicy;
26
+ loginLimiter: RateLimiter;
27
+ }
28
+ export declare function authRoutes(deps: AuthRoutesDeps): Hono<Env>;
29
+ //# sourceMappingURL=routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAG5B,OAAO,EAAsB,KAAK,GAAG,EAAE,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAElF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACtD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,EAIL,KAAK,cAAc,EACpB,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAmB,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AACnF,OAAO,EAKL,KAAK,WAAW,EACjB,MAAM,WAAW,CAAC;AAiBnB,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,cAAc,CAAC;IACzB,YAAY,EAAE,iBAAiB,CAAC;IAChC,IAAI,EAAE,WAAW,CAAC;IAClB,SAAS,EAAE,eAAe,CAAC;IAC3B,YAAY,EAAE,WAAW,CAAC;CAC3B;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,IAAI,CAAC,GAAG,CAAC,CAkI1D"}
package/dist/routes.js ADDED
@@ -0,0 +1,135 @@
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
+ import { Hono } from "hono";
14
+ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
15
+ import { ApiError, clientIp } from "@micacms/runtime";
16
+ import { requireAccess, sessionOf } from "./gate.js";
17
+ import { CSRF_COOKIE, establishSession, SESSION_COOKIE, } from "./session.js";
18
+ import { openTransaction, sealTransaction, TRANSACTION_COOKIE, } from "./oidc.js";
19
+ /** Only ever a path on this server, so the callback cannot be an open redirect. */
20
+ function safeReturnTo(raw) {
21
+ if (!raw || !raw.startsWith("/") || raw.startsWith("//"))
22
+ return "/admin/";
23
+ return raw;
24
+ }
25
+ const identityOf = (claims) => ({
26
+ issuer: claims.issuer,
27
+ subject: claims.subject,
28
+ email: claims.email,
29
+ emailVerified: claims.emailVerified,
30
+ name: claims.name,
31
+ groups: claims.groups,
32
+ });
33
+ export function authRoutes(deps) {
34
+ const app = new Hono();
35
+ const { config, sessions, oidc, admission } = deps;
36
+ const gate = deps;
37
+ /**
38
+ * What the sign-in screen needs to know before it draws anything.
39
+ *
40
+ * Public: it reveals only that this deployment authenticates and which
41
+ * provider it uses, which the redirect would reveal a moment later anyway.
42
+ */
43
+ app.get("/config", (c) => c.json({
44
+ authRequired: true,
45
+ provider: config.MICA_OIDC_ISSUER ? new URL(config.MICA_OIDC_ISSUER).hostname : null,
46
+ }));
47
+ /** Starts the flow. A redirect, not a form post. */
48
+ app.get("/login", async (c) => {
49
+ const returnTo = safeReturnTo(c.req.query("returnTo"));
50
+ // Rate limited on the way out as well as the way back: starting a flow is
51
+ // cheap for a client and costs Mica a discovery request.
52
+ const limit = deps.loginLimiter.check(`oidc:${clientIp(c)}`);
53
+ if (!limit.allowed) {
54
+ throw new ApiError("rate_limited", `Too many sign-in attempts. Try again in ${Math.ceil(limit.retryAfter / 60)} minutes.`, { headers: { "retry-after": String(limit.retryAfter) } });
55
+ }
56
+ const { url, transaction } = await oidc.begin(returnTo);
57
+ setCookie(c, TRANSACTION_COOKIE, sealTransaction(transaction, config.sessionSecret), {
58
+ path: "/api/auth",
59
+ httpOnly: true,
60
+ secure: config.cookieSecure,
61
+ // Lax, not Strict: the provider redirects the browser back here from
62
+ // another origin, and Strict would withhold the cookie exactly then.
63
+ sameSite: "Lax",
64
+ maxAge: 15 * 60,
65
+ });
66
+ return c.redirect(url);
67
+ });
68
+ /**
69
+ * Where the provider sends the browser back.
70
+ *
71
+ * A browser is following a redirect here, not making an XHR, so a failure
72
+ * must not be a JSON error envelope — that would put a wall of braces on the
73
+ * screen. Everything that can go wrong goes back to the sign-in page with a
74
+ * message the person can act on.
75
+ */
76
+ app.get("/callback", async (c) => {
77
+ const logger = c.get("logger");
78
+ const refuse = (message) => c.redirect(`/admin/login?error=${encodeURIComponent(message)}`);
79
+ const sealed = getCookie(c, TRANSACTION_COOKIE);
80
+ deleteCookie(c, TRANSACTION_COOKIE, { path: "/api/auth" });
81
+ const transaction = sealed ? openTransaction(sealed, config.sessionSecret) : null;
82
+ if (!transaction) {
83
+ // Expired, tampered with, or a callback nobody asked for.
84
+ return refuse("That sign-in attempt expired or was not started here. Please try again.");
85
+ }
86
+ let claims;
87
+ try {
88
+ claims = await oidc.complete(new URL(c.req.url), transaction);
89
+ }
90
+ catch (error) {
91
+ return refuse(error instanceof ApiError ? error.message : "Sign-in could not be completed.");
92
+ }
93
+ const identity = identityOf(claims);
94
+ /**
95
+ * Authentication succeeded; admission is a separate question.
96
+ *
97
+ * A provider may vouch for an entire company. The admission rules are what
98
+ * say this particular person may edit this particular site, and their
99
+ * refusal is the expected answer for everybody who was not meant to be
100
+ * here — so it is a plain message, not an error.
101
+ */
102
+ const verdict = admission.admits(identity);
103
+ if (!verdict.admitted) {
104
+ logger.warn("Authenticated but not admitted", {
105
+ subject: identity.subject,
106
+ reason: verdict.reason,
107
+ });
108
+ return refuse(`You are signed in as ${claims.email}, but that account has no access to ` +
109
+ "this site. Ask whoever runs it to add you.");
110
+ }
111
+ establishSession(c, sessions, identity);
112
+ logger.info("Signed in", { subject: identity.subject, issuer: identity.issuer });
113
+ return c.redirect(transaction.returnTo);
114
+ });
115
+ app.post("/logout", async (c) => {
116
+ sessions.destroy(getCookie(c, SESSION_COOKIE));
117
+ deleteCookie(c, SESSION_COOKIE, sessions.clearedCookieOptions());
118
+ deleteCookie(c, CSRF_COOKIE, sessions.clearedCookieOptions());
119
+ // Offered, not followed: ending the provider's session is the provider's
120
+ // decision to make, and the SPA sends the browser there if it is there.
121
+ return c.json({ ok: true, endSession: await oidc.endSessionUrl() });
122
+ });
123
+ /** The session this request is in, and who it belongs to. */
124
+ app.get("/me", requireAccess(gate), (c) => {
125
+ const session = sessionOf(c);
126
+ const { identity } = c.get("access");
127
+ return c.json({
128
+ identity,
129
+ csrfToken: session.csrfToken,
130
+ expiresAt: session.expiresAt,
131
+ });
132
+ });
133
+ return app;
134
+ }
135
+ //# sourceMappingURL=routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Session management.
3
+ *
4
+ * Server-side sessions in SQLite, addressed by an opaque random token held in a
5
+ * Secure/HttpOnly/SameSite cookie. No JWTs: revocation has to be immediate, and
6
+ * a row delete is the simplest way to get it.
7
+ *
8
+ * Three layers of defence on the cookie value:
9
+ * 1. the token is 256 bits from `crypto.randomBytes`;
10
+ * 2. it is HMAC-signed with SESSION_SECRET, so forged or truncated cookies
11
+ * are rejected without touching the database;
12
+ * 3. only its SHA-256 is stored, so a database dump yields no usable session.
13
+ *
14
+ * Session fixation is handled by never adopting a client-supplied identifier —
15
+ * a token is minted at login and the previous one deleted.
16
+ */
17
+ import type { Context } from "hono";
18
+ import { type Env } from "@micacms/runtime";
19
+ import type { AuthConfig } from "./config.js";
20
+ import type { SessionIdentity, SessionRecord, SessionRepository } from "./sessions-repository.js";
21
+ export declare const SESSION_COOKIE = "mica_session";
22
+ export declare const CSRF_COOKIE = "mica_csrf";
23
+ export declare const CSRF_HEADER = "x-csrf-token";
24
+ export interface SessionService {
25
+ /**
26
+ * `identity` is null in local mode, where there is no provider and no user:
27
+ * the row exists to carry a CSRF token and an expiry, and nothing else.
28
+ */
29
+ create(input: {
30
+ identity: SessionIdentity | null;
31
+ ip?: string | null;
32
+ userAgent?: string | null;
33
+ }): {
34
+ record: SessionRecord;
35
+ cookie: string;
36
+ csrfToken: string;
37
+ };
38
+ resolve(cookieValue: string | undefined): SessionRecord | null;
39
+ destroy(cookieValue: string | undefined): void;
40
+ /** Signs one person out on every device. */
41
+ destroyAllForIdentity(issuer: string, subject: string): void;
42
+ sessionCookieOptions(): CookieOptions;
43
+ csrfCookieOptions(): CookieOptions;
44
+ clearedCookieOptions(): CookieOptions;
45
+ purgeExpired(): number;
46
+ }
47
+ export interface CookieOptions {
48
+ path: string;
49
+ httpOnly: boolean;
50
+ secure: boolean;
51
+ sameSite: "Lax" | "Strict" | "None";
52
+ maxAge: number;
53
+ }
54
+ /** Constant-time string comparison that tolerates differing lengths. */
55
+ export declare function safeEqual(a: string, b: string): boolean;
56
+ export declare function createSessionService(sessions: SessionRepository, config: AuthConfig): SessionService;
57
+ /** Issues the session cookies for a browser that has just been identified. */
58
+ export declare function establishSession(c: Context<Env>, sessions: SessionService, identity: SessionIdentity): void;
59
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAGpC,OAAO,EAAyB,KAAK,GAAG,EAAE,MAAM,kBAAkB,CAAC;AAEnE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EACV,eAAe,EACf,aAAa,EACb,iBAAiB,EAClB,MAAM,0BAA0B,CAAC;AAElC,eAAO,MAAM,cAAc,iBAAiB,CAAC;AAC7C,eAAO,MAAM,WAAW,cAAc,CAAC;AACvC,eAAO,MAAM,WAAW,iBAAiB,CAAC;AAK1C,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,MAAM,CAAC,KAAK,EAAE;QACZ,QAAQ,EAAE,eAAe,GAAG,IAAI,CAAC;QACjC,EAAE,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QACnB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KAC3B,GAAG;QAAE,MAAM,EAAE,aAAa,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IACjE,OAAO,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,aAAa,GAAG,IAAI,CAAC;IAC/D,OAAO,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAAC;IAC/C,4CAA4C;IAC5C,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7D,oBAAoB,IAAI,aAAa,CAAC;IACtC,iBAAiB,IAAI,aAAa,CAAC;IACnC,oBAAoB,IAAI,aAAa,CAAC;IACtC,YAAY,IAAI,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACpC,MAAM,EAAE,MAAM,CAAC;CAChB;AAQD,wEAAwE;AACxE,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CASvD;AAED,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,iBAAiB,EAC3B,MAAM,EAAE,UAAU,GACjB,cAAc,CA2GhB;AAMD,8EAA8E;AAC9E,wBAAgB,gBAAgB,CAC9B,CAAC,EAAE,OAAO,CAAC,GAAG,CAAC,EACf,QAAQ,EAAE,cAAc,EACxB,QAAQ,EAAE,eAAe,GACxB,IAAI,CAYN"}