@o3co/create-auth-provider 0.15.0 → 0.16.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.
Files changed (41) hide show
  1. package/README.ja.md +93 -47
  2. package/README.md +92 -46
  3. package/package.json +1 -1
  4. package/templates/standalone/.env.example +12 -0
  5. package/templates/standalone/Dockerfile +1 -1
  6. package/templates/standalone/README.ja.md +516 -154
  7. package/templates/standalone/README.md +218 -91
  8. package/templates/standalone/config/application.conf +26 -3
  9. package/templates/standalone/gitignore +3 -2
  10. package/templates/standalone/package.json +1 -1
  11. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +49 -15
  12. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1103 -0
  13. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +241 -0
  14. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1100 -0
  15. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +19 -5
  16. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +64 -3
  17. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +15 -0
  18. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +133 -0
  19. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +28 -0
  20. package/templates/standalone/src/__tests__/google-session.fixture.mts +304 -0
  21. package/templates/standalone/src/__tests__/host-routes.test.mts +141 -0
  22. package/templates/standalone/src/__tests__/listen.test.mts +128 -0
  23. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  24. package/templates/standalone/src/__tests__/metrics.test.mts +20 -5
  25. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +187 -0
  26. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +139 -0
  27. package/templates/standalone/src/__tests__/replica-safety.test.mts +13 -3
  28. package/templates/standalone/src/__tests__/router-error-answers.test.mts +149 -0
  29. package/templates/standalone/src/__tests__/shutdown.test.mts +157 -20
  30. package/templates/standalone/src/__tests__/smoke.test.mts +39 -20
  31. package/templates/standalone/src/__tests__/store-credential.test.mts +276 -0
  32. package/templates/standalone/src/app.mts +21 -50
  33. package/templates/standalone/src/buildModules.mts +33 -16
  34. package/templates/standalone/src/listen.mts +66 -0
  35. package/templates/standalone/src/logger.mts +7 -4
  36. package/templates/standalone/src/modules.mts +42 -8
  37. package/templates/standalone/src/routes.mts +95 -0
  38. package/templates/standalone/src/shutdown.mts +20 -14
  39. package/templates/versions.json +15 -15
  40. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  41. package/templates/standalone/src/terminalError.mts +0 -60
@@ -0,0 +1,95 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /*
18
+ * The host's routes, in the order `app.mts` mounts them: liveness, readiness
19
+ * and the metrics scrape ahead of the composed auth router — so they keep
20
+ * answering while the auth pipeline is degraded, which is when an operator
21
+ * needs them — then that router, then core's terminal error handler, last,
22
+ * because Express hands an error only to handlers mounted after the route
23
+ * that raised it. A function rather than inline in `app.mts`, so the order
24
+ * and the handler can be tested as the process mounts them.
25
+ *
26
+ * The handler is core's `terminalErrorHandler`, the one that already ends
27
+ * the composed router, so an error one of the host's routes lets through
28
+ * is answered as every other route's is: `500 server_error` /
29
+ * `unexpected_error` in the RFC 6749 envelope with `Cache-Control:
30
+ * no-store`, logged once as `unhandled_request_error` with the path through
31
+ * `auditErrorText` and the error's `loggableError` projection; a refusal an
32
+ * `http-errors` error marks as the client's keeps its 4xx; a response whose
33
+ * headers already went out is closed rather than handed to Express's final
34
+ * handler, which would print the stack.
35
+ */
36
+
37
+ import {
38
+ createHealthcheckRouter,
39
+ createReadinessRouter,
40
+ type Logger,
41
+ type ReadinessProbe,
42
+ terminalErrorHandler,
43
+ } from "@o3co/auth-provider-core";
44
+ import express, { type Express, type Router } from "express";
45
+ import type { Metrics } from "./metrics.mjs";
46
+
47
+ export interface MountRoutesOptions {
48
+ /** The router `createApp` composed (`handle.router`). */
49
+ readonly router: Router;
50
+ /** The readiness probes the builders registered (`handle.readinessProbes`). */
51
+ readonly probes: readonly ReadinessProbe[];
52
+ /** Per-probe deadline, in milliseconds: `config.http.readinessTimeoutMs`. */
53
+ readonly readinessTimeoutMs: number;
54
+ readonly metrics: Metrics;
55
+ readonly logger: Logger;
56
+ }
57
+
58
+ /** Mount the host's routes, the composed router and the terminal error handler on `app`. */
59
+ export function mountRoutes(app: Express, options: MountRoutesOptions): void {
60
+ // Liveness: the process is up and its event loop is turning. Deliberately
61
+ // static — restarting the process would not bring Redis back, so a Redis
62
+ // outage must not read as "this container is broken, kill it".
63
+ app.use(createHealthcheckRouter(express));
64
+
65
+ // Readiness: can this replica serve right now? Redis backs sessions,
66
+ // authorization codes and refresh-token families in the deployable
67
+ // defaults, so a replica that has lost it answers 503 here and should be
68
+ // taken out of rotation. Probes are contributed by the builders that own
69
+ // each connection; a memory-only deployment registers none and is always
70
+ // ready.
71
+ app.use(
72
+ createReadinessRouter(express, {
73
+ probes: options.probes,
74
+ timeoutMs: options.readinessTimeoutMs,
75
+ logger: options.logger,
76
+ }),
77
+ );
78
+
79
+ // Prometheus scrape endpoint. Same reasoning as the probes above: it has to
80
+ // keep answering while the auth pipeline is degraded, because that is when
81
+ // the series matter.
82
+ app.use(
83
+ options.metrics.route(express, {
84
+ probes: options.probes,
85
+ probeTimeoutMs: options.readinessTimeoutMs,
86
+ }),
87
+ );
88
+
89
+ // The composed auth router, which ends in core's terminal handler.
90
+ app.use(options.router);
91
+ // Core's terminal handler again, LAST (#293 item 8), for the host routes
92
+ // above: Express routes an error only to handlers registered after the
93
+ // route that threw it. See the file header.
94
+ app.use(terminalErrorHandler(options.logger));
95
+ }
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { Server } from "node:http";
18
- import type { Logger } from "@o3co/auth-provider-core";
18
+ import { type Logger, loggableError } from "@o3co/auth-provider-core";
19
19
 
20
20
  /**
21
21
  * Graceful shutdown for the scaffolded server (#290).
@@ -54,13 +54,20 @@ import type { Logger } from "@o3co/auth-provider-core";
54
54
  * and the process exits **non-zero** — an orchestrator that only ever sees
55
55
  * `0` cannot tell a clean drain from one that ran out of time.
56
56
  * 5. **`cleanup` runs after draining, before exit**, and its failure is logged
57
- * through the app logger and reflected in the exit code. It never wedges the
58
- * process: a dispose that throws still exits, and one that never settles is
59
- * cut off at `cleanupTimeoutMs`.
57
+ * through the app logger — as core's `loggableError` projection, never the
58
+ * error, which holds every cleanup's own error — and reflected in the exit
59
+ * code. It never wedges the process: a dispose that throws still exits, and
60
+ * one that never settles is cut off at `cleanupTimeoutMs`.
60
61
  * 6. **A `close` that fails is not reported as a clean drain.** `server.close`
61
62
  * reports through its callback, and treating that as success would tell an
62
63
  * orchestrator the listener came down when it did not.
63
64
  *
65
+ * Every stage is one object-first line under a snake_case event name:
66
+ * `shutdown_draining` (info, `drainTimeoutMs`), `shutdown_drain_deadline_exceeded`
67
+ * (error), `shutdown_server_close_failed` (error, `err`), `shutdown_cleanup_timed_out`
68
+ * (error, `cleanupTimeoutMs`), `shutdown_cleanup_failed` (error, `err`) and
69
+ * `shutdown_complete` (info, `reason`, `drain`, `exitCode`).
70
+ *
64
71
  * Size `drainTimeoutMs` plus `cleanupTimeoutMs` **below** the orchestrator's
65
72
  * own kill grace period (Kubernetes `terminationGracePeriodSeconds` is 30s by
66
73
  * default, compose `stop_grace_period` 10s; with federation grants on the sum
@@ -226,7 +233,7 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
226
233
  let outcome = reason;
227
234
  try {
228
235
  if ((await runCleanup()) === CLEANUP_TIMED_OUT) {
229
- logger.error({ cleanupTimeoutMs }, "graceful shutdown: cleanup timed out");
236
+ logger.error({ cleanupTimeoutMs }, "shutdown_cleanup_timed_out");
230
237
  exitCode = 1;
231
238
  outcome = "cleanup-timeout";
232
239
  }
@@ -234,12 +241,14 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
234
241
  // Through the app logger, not `console.error`: a shutdown that
235
242
  // failed to release its Redis connections is exactly the line an
236
243
  // operator needs to find later, and a bare write is the one their
237
- // pipeline drops.
238
- logger.error({ err }, "graceful shutdown: cleanup failed");
244
+ // pipeline drops. The projection, not the error: `dispose()`
245
+ // rejects with every cleanup's own error on `errors`, a store's
246
+ // write — and what it wrote — among them.
247
+ logger.error({ err: loggableError(err) }, "shutdown_cleanup_failed");
239
248
  exitCode = 1;
240
249
  outcome = "cleanup-failed";
241
250
  }
242
- logger.info({ reason: outcome, drain: reason, exitCode }, "graceful shutdown: complete");
251
+ logger.info({ reason: outcome, drain: reason, exitCode }, "shutdown_complete");
243
252
  exit(exitCode);
244
253
  };
245
254
 
@@ -247,13 +256,10 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
247
256
  if (shuttingDown) return;
248
257
  shuttingDown = true;
249
258
  for (const signal of SIGNALS) offSignal(signal, handler);
250
- logger.info({ drainTimeoutMs }, "graceful shutdown: draining");
259
+ logger.info({ drainTimeoutMs }, "shutdown_draining");
251
260
 
252
261
  const deadline = setTimeout(() => {
253
- logger.error(
254
- { drainTimeoutMs },
255
- "graceful shutdown: drain deadline exceeded, closing remaining connections",
256
- );
262
+ logger.error({ drainTimeoutMs }, "shutdown_drain_deadline_exceeded");
257
263
  server.closeAllConnections();
258
264
  void finish(1, "drain-timeout");
259
265
  }, drainTimeoutMs);
@@ -269,7 +275,7 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
269
275
  // here. Reporting "drained" and exiting 0 on it would tell an
270
276
  // orchestrator the shutdown went cleanly when the listener did
271
277
  // not actually come down.
272
- logger.error({ err }, "graceful shutdown: server close failed");
278
+ logger.error({ err: loggableError(err) }, "shutdown_server_close_failed");
273
279
  void finish(1, "close-failed");
274
280
  return;
275
281
  }
@@ -1,17 +1,17 @@
1
1
  {
2
- "@o3co/auth-provider-core": "0.15.0",
3
- "@o3co/auth-provider-device-grant": "0.15.0",
4
- "@o3co/auth-provider-dpop": "0.15.0",
5
- "@o3co/auth-provider-mtls": "0.15.0",
6
- "@o3co/auth-provider-federation-apple": "0.15.0",
7
- "@o3co/auth-provider-federation-google": "0.15.0",
8
- "@o3co/auth-provider-federation-github": "0.15.0",
9
- "@o3co/auth-provider-federation-grants": "0.15.0",
10
- "@o3co/auth-provider-federation-oidc": "0.15.0",
11
- "@o3co/auth-provider-oauth": "0.15.0",
12
- "@o3co/auth-provider-oauth-token-exchange": "0.15.0",
13
- "@o3co/auth-provider-session": "0.15.0",
14
- "@o3co/auth-provider-foundation": "0.15.0",
15
- "@o3co/auth-provider-redis": "0.15.0",
16
- "@o3co/auth-provider-webauthn": "0.15.0"
2
+ "@o3co/auth-provider-core": "0.16.0",
3
+ "@o3co/auth-provider-device-grant": "0.16.0",
4
+ "@o3co/auth-provider-dpop": "0.16.0",
5
+ "@o3co/auth-provider-mtls": "0.16.0",
6
+ "@o3co/auth-provider-federation-apple": "0.16.0",
7
+ "@o3co/auth-provider-federation-google": "0.16.0",
8
+ "@o3co/auth-provider-federation-github": "0.16.0",
9
+ "@o3co/auth-provider-federation-grants": "0.16.0",
10
+ "@o3co/auth-provider-federation-oidc": "0.16.0",
11
+ "@o3co/auth-provider-oauth": "0.16.0",
12
+ "@o3co/auth-provider-oauth-token-exchange": "0.16.0",
13
+ "@o3co/auth-provider-session": "0.16.0",
14
+ "@o3co/auth-provider-foundation": "0.16.0",
15
+ "@o3co/auth-provider-redis": "0.16.0",
16
+ "@o3co/auth-provider-webauthn": "0.16.0"
17
17
  }
@@ -1,96 +0,0 @@
1
- /*
2
- * Copyright 2026 1o1 Co. Ltd.
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
- import type { Logger } from "@o3co/auth-provider-core";
17
- import express from "express";
18
- import request from "supertest";
19
- import { describe, expect, it, vi } from "vitest";
20
- import { createTerminalErrorHandler } from "../terminalError.mjs";
21
-
22
- const makeLogger = () => {
23
- const error = vi.fn();
24
- const logger = {
25
- error,
26
- warn: vi.fn(),
27
- info: vi.fn(),
28
- debug: vi.fn(),
29
- } as unknown as Logger;
30
- return { logger, error };
31
- };
32
-
33
- const makeApp = (logger: Logger) => {
34
- const app = express();
35
- app.use(express.json());
36
- app.post("/echo", (_req, res) => {
37
- res.status(200).json({ ok: true });
38
- });
39
- app.get("/boom", () => {
40
- throw new Error("route exploded");
41
- });
42
- app.use(createTerminalErrorHandler(logger));
43
- return app;
44
- };
45
-
46
- describe("terminal error handler (#293 item 8)", () => {
47
- it("answers malformed JSON with the client's 400 in the shared envelope, not Express's HTML page", async () => {
48
- const { logger, error } = makeLogger();
49
- const res = await request(makeApp(logger))
50
- .post("/echo")
51
- .set("Content-Type", "application/json")
52
- .send("{not json");
53
-
54
- expect(res.status).toBe(400);
55
- expect(res.headers["content-type"]).toContain("application/json");
56
- expect(res.body.error).toBe("invalid_request");
57
- // The client's malformed body is not a server error worth an error line.
58
- expect(error).not.toHaveBeenCalled();
59
- });
60
-
61
- it("keeps the body parser's own status for an over-limit body (413)", async () => {
62
- const { logger } = makeLogger();
63
- const app = express();
64
- app.use(express.json({ limit: 16 }));
65
- app.post("/echo", (_req, res) => {
66
- res.status(200).json({ ok: true });
67
- });
68
- app.use(createTerminalErrorHandler(logger));
69
-
70
- const res = await request(app)
71
- .post("/echo")
72
- .set("Content-Type", "application/json")
73
- .send(JSON.stringify({ pad: "x".repeat(64) }));
74
-
75
- expect(res.status).toBe(413);
76
- expect(res.body.error).toBe("invalid_request");
77
- expect(res.body.error_description).toBe("request body too large");
78
- });
79
-
80
- it("answers an unexpected route throw with 500 server_error and logs it structurally", async () => {
81
- const { logger, error } = makeLogger();
82
- const res = await request(makeApp(logger)).get("/boom");
83
-
84
- expect(res.status).toBe(500);
85
- expect(res.body).toEqual({
86
- error: "server_error",
87
- error_description: "Internal server error",
88
- });
89
- // No stack trace on the wire — the envelope is the whole body.
90
- expect(JSON.stringify(res.body)).not.toContain("route exploded");
91
- expect(error).toHaveBeenCalledWith(
92
- expect.objectContaining({ endpoint: "/boom" }),
93
- "unhandled_request_error",
94
- );
95
- });
96
- });
@@ -1,60 +0,0 @@
1
- /*
2
- * Copyright 2026 1o1 Co. Ltd.
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
- import { errorEnvelope, type Logger } from "@o3co/auth-provider-core";
17
- import type { ErrorRequestHandler } from "express";
18
-
19
- /**
20
- * Terminal Express error handler (#293 item 8).
21
- *
22
- * Anything a route threw past its own try/catch — and every body-parser
23
- * rejection, which fires before any route runs — used to fall through to
24
- * Express's default handler: an HTML 500 outside the structured-log pipeline,
25
- * carrying a stack trace outside production. A client that parses only the
26
- * RFC 6749 §5.2 JSON envelope this surface answers with everywhere else has
27
- * no way to read that page.
28
- *
29
- * Body-parser failures (malformed JSON/form, over-limit, bad charset) carry
30
- * their own 4xx `status` and are the client's fault: keep the status, wrap it
31
- * in the shared envelope, and do not log them as server errors. Everything
32
- * else is logged with the request path and answered `500 server_error`.
33
- *
34
- * Mount it AFTER every route (`app.use(handle.router)` included) — Express
35
- * routes errors only to handlers registered later.
36
- */
37
- export const createTerminalErrorHandler = (logger: Logger): ErrorRequestHandler => {
38
- return (err, req, res, next) => {
39
- // A failure after the response started is not ours to rewrite; handing
40
- // it back lets Express close the connection.
41
- if (res.headersSent) {
42
- next(err);
43
- return;
44
- }
45
- const status = (err as { status?: unknown; statusCode?: unknown } | null) ?? {};
46
- const httpStatus =
47
- typeof status.status === "number"
48
- ? status.status
49
- : typeof status.statusCode === "number"
50
- ? status.statusCode
51
- : undefined;
52
- if (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500) {
53
- const description = httpStatus === 413 ? "request body too large" : "malformed request body";
54
- res.status(httpStatus).json(errorEnvelope("invalid_request", description));
55
- return;
56
- }
57
- logger.error({ err, endpoint: req.path }, "unhandled_request_error");
58
- res.status(500).json(errorEnvelope("server_error", "Internal server error"));
59
- };
60
- };