@o3co/create-auth-provider 0.9.0 → 0.11.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 (37) hide show
  1. package/dist/index.d.mts +20 -0
  2. package/dist/index.d.mts.map +1 -1
  3. package/dist/index.mjs +110 -7
  4. package/package.json +4 -4
  5. package/templates/standalone/.env.example +49 -5
  6. package/templates/standalone/Dockerfile +59 -16
  7. package/templates/standalone/README.ja.md +92 -13
  8. package/templates/standalone/README.md +358 -18
  9. package/templates/standalone/config/application.conf +132 -4
  10. package/templates/standalone/config/clients.yaml.example +56 -2
  11. package/templates/standalone/config/production.conf +9 -3
  12. package/templates/standalone/docker-compose.production.yml +132 -0
  13. package/templates/standalone/docker-compose.yml +7 -0
  14. package/templates/standalone/gitignore +21 -0
  15. package/templates/standalone/package.json +15 -20
  16. package/templates/standalone/src/__tests__/audit-sink.test.mts +343 -0
  17. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +423 -0
  18. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +114 -0
  19. package/templates/standalone/src/__tests__/metrics.test.mts +231 -0
  20. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +76 -1
  21. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +117 -0
  22. package/templates/standalone/src/__tests__/shutdown.test.mts +244 -0
  23. package/templates/standalone/src/__tests__/smoke.test.mts +213 -11
  24. package/templates/standalone/src/__tests__/terminal-error.test.mts +96 -0
  25. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +117 -7
  26. package/templates/standalone/src/app.mts +78 -10
  27. package/templates/standalone/src/buildModules.mts +43 -1
  28. package/templates/standalone/src/configPath.mts +2 -2
  29. package/templates/standalone/src/logger.mts +90 -2
  30. package/templates/standalone/src/metrics.mts +190 -0
  31. package/templates/standalone/src/modules.mts +319 -33
  32. package/templates/standalone/src/shutdown.mts +162 -0
  33. package/templates/standalone/src/terminalError.mts +60 -0
  34. package/templates/standalone/tests/index.test.js +69 -5
  35. package/templates/standalone/tsconfig.json +9 -1
  36. package/templates/standalone/vitest.config.mts +8 -0
  37. package/templates/versions.json +12 -11
@@ -0,0 +1,60 @@
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
+ };
@@ -29,6 +29,23 @@ const client = axios.create({
29
29
 
30
30
  const _extractCookies = (res) => (res.headers["set-cookie"] ?? []).join("; ");
31
31
 
32
+ /**
33
+ * The server's own origin, as a browser would report it.
34
+ *
35
+ * Since #272 `POST /session/login` and `POST /session/logout` reject a request
36
+ * that carries neither a same-origin `Origin`/`Referer` nor a valid
37
+ * double-submit CSRF token. A browser sets `Origin` on its own; a scripted
38
+ * client like this one has to say it.
39
+ */
40
+ const SERVER_ORIGIN = new URL(BASE_URL).origin;
41
+
42
+ /** POST to a state-changing session route the way a same-origin browser would. */
43
+ const sessionPost = (path, data, config = {}) =>
44
+ client.post(path, data, {
45
+ ...config,
46
+ headers: { Origin: SERVER_ORIGIN, ...(config.headers ?? {}) },
47
+ });
48
+
32
49
  describe("POST /oauth/introspect", () => {
33
50
  // Helper: self-introspect sends the token as both Bearer credential and body parameter
34
51
  const selfIntrospect = (token, contentType = "application/json") => {
@@ -109,12 +126,12 @@ describe("POST /oauth/introspect", () => {
109
126
 
110
127
  describe("POST /session/login", () => {
111
128
  it("returns 400 when no credentials are provided", async () => {
112
- const res = await client.post("/session/login", {});
129
+ const res = await sessionPost("/session/login", {});
113
130
  expect(res.status).toBe(400);
114
131
  });
115
132
 
116
133
  it("returns 400 when redirect_to has an invalid scheme", async () => {
117
- const res = await client.post("/session/login", {
134
+ const res = await sessionPost("/session/login", {
118
135
  username: "testuser",
119
136
  password: "testpass",
120
137
  redirect_to: "javascript:alert(1)",
@@ -124,7 +141,7 @@ describe("POST /session/login", () => {
124
141
  });
125
142
 
126
143
  it("returns 400 when redirect_to is not a valid URL", async () => {
127
- const res = await client.post("/session/login", {
144
+ const res = await sessionPost("/session/login", {
128
145
  username: "testuser",
129
146
  password: "testpass",
130
147
  redirect_to: "not-a-url",
@@ -134,7 +151,7 @@ describe("POST /session/login", () => {
134
151
  });
135
152
 
136
153
  it("returns 400 when redirect_to exceeds 2048 characters", async () => {
137
- const res = await client.post("/session/login", {
154
+ const res = await sessionPost("/session/login", {
138
155
  username: "testuser",
139
156
  password: "testpass",
140
157
  redirect_to: `https://example.com/${"a".repeat(2048)}`,
@@ -147,7 +164,7 @@ describe("POST /session/login", () => {
147
164
  // redirect_to validation runs before authentication, so even with
148
165
  // invalid credentials, a valid redirect_to should not cause a 400
149
166
  // with error: "invalid_redirect". It should fail with 401 (auth failure).
150
- const res = await client.post("/session/login", {
167
+ const res = await sessionPost("/session/login", {
151
168
  username: "testuser",
152
169
  password: "testpass",
153
170
  redirect_to: "http://localhost:3000/oauth/authorize",
@@ -157,6 +174,53 @@ describe("POST /session/login", () => {
157
174
  });
158
175
  });
159
176
 
177
+ describe("CSRF on the state-changing session routes (#272)", () => {
178
+ it("rejects a login carrying neither an origin signal nor a token", async () => {
179
+ // The bypass this replaced: omitting `Origin` skipped the check entirely.
180
+ const res = await client.post("/session/login", {
181
+ username: "testuser",
182
+ password: "testpass",
183
+ });
184
+ expect(res.status).toBe(403);
185
+ expect(res.data.error).toBe("access_denied");
186
+ });
187
+
188
+ it("rejects a logout carrying neither an origin signal nor a token", async () => {
189
+ const res = await client.post("/session/logout", {});
190
+ expect(res.status).toBe(403);
191
+ expect(res.data.error).toBe("access_denied");
192
+ });
193
+
194
+ it("rejects a login from a foreign origin", async () => {
195
+ const res = await client.post(
196
+ "/session/login",
197
+ { username: "testuser", password: "testpass" },
198
+ { headers: { Origin: "https://evil.example.com" } },
199
+ );
200
+ expect(res.status).toBe(403);
201
+ });
202
+
203
+ it("accepts a header-less client that presents a token from GET /session/csrf", async () => {
204
+ const issued = await client.get("/session/csrf");
205
+ expect(issued.status).toBe(200);
206
+ expect(typeof issued.data.csrf_token).toBe("string");
207
+
208
+ const res = await client.post(
209
+ "/session/login",
210
+ { username: "testuser", password: "testpass" },
211
+ {
212
+ headers: {
213
+ Cookie: _extractCookies(issued).split(";")[0],
214
+ [issued.data.header_name]: issued.data.csrf_token,
215
+ },
216
+ },
217
+ );
218
+ // Credentials are still wrong in this environment — the point is that the
219
+ // request got past the CSRF guard rather than stopping at 403.
220
+ expect(res.status).not.toBe(403);
221
+ });
222
+ });
223
+
160
224
  describe("POST /oauth/token", () => {
161
225
  it("returns 401 for grant_type=session without an authenticated session", async () => {
162
226
  const res = await client.post("/oauth/token", "grant_type=session", {
@@ -1,6 +1,14 @@
1
1
  {
2
2
  "compilerOptions": {
3
- "target": "esnext",
3
+ // `es2023` target (NOT esnext) with `lib: esnext` — mirrors the monorepo's
4
+ // tsconfig.base.json. This template is copied verbatim into scaffolded
5
+ // consumer projects (so it must stay self-contained and cannot `extends`
6
+ // ../../tsconfig.base.json). Capping the emit target at ES2023 — the newest
7
+ // edition Node 22 (this template's engine floor) parses natively — makes tsc
8
+ // downlevel any newer syntax (e.g. `await using`) into helpers, so the built
9
+ // server actually runs on the Node it advertises. Do NOT raise to `esnext`
10
+ // without also raising the engine floor.
11
+ "target": "es2023",
4
12
  "lib": ["esnext"],
5
13
  "module": "nodenext",
6
14
  "moduleResolution": "nodenext",
@@ -3,5 +3,13 @@ import { defineConfig } from "vitest/config";
3
3
  export default defineConfig({
4
4
  test: {
5
5
  include: ["src/**/__tests__/**/*.test.mts"],
6
+ // #357: the workspace-wide deadline floor, repeated as literals rather
7
+ // than imported from vitest.shared.mts — this template is copied
8
+ // verbatim into scaffolded projects, where that file does not exist.
9
+ // Rationale lives there: under a loaded parallel run, transform+import
10
+ // alone can exceed vitest's 5s default, and a real hang still fails at
11
+ // 20s.
12
+ testTimeout: 20_000,
13
+ hookTimeout: 20_000,
6
14
  },
7
15
  });
@@ -1,13 +1,14 @@
1
1
  {
2
- "@o3co/auth-provider-core": "0.9.0",
3
- "@o3co/auth-provider-dpop": "0.9.0",
4
- "@o3co/auth-provider-mtls": "0.9.0",
5
- "@o3co/auth-provider-federation-google": "0.9.0",
6
- "@o3co/auth-provider-federation-github": "0.9.0",
7
- "@o3co/auth-provider-oauth": "0.9.0",
8
- "@o3co/auth-provider-oauth-token-exchange": "0.9.0",
9
- "@o3co/auth-provider-session": "0.9.0",
10
- "@o3co/auth-provider-foundation": "0.9.0",
11
- "@o3co/auth-provider-redis": "0.9.0",
12
- "@o3co/auth-provider-webauthn": "0.9.0"
2
+ "@o3co/auth-provider-core": "0.11.0",
3
+ "@o3co/auth-provider-device-grant": "0.11.0",
4
+ "@o3co/auth-provider-dpop": "0.11.0",
5
+ "@o3co/auth-provider-mtls": "0.11.0",
6
+ "@o3co/auth-provider-federation-google": "0.11.0",
7
+ "@o3co/auth-provider-federation-github": "0.11.0",
8
+ "@o3co/auth-provider-oauth": "0.11.0",
9
+ "@o3co/auth-provider-oauth-token-exchange": "0.11.0",
10
+ "@o3co/auth-provider-session": "0.11.0",
11
+ "@o3co/auth-provider-foundation": "0.11.0",
12
+ "@o3co/auth-provider-redis": "0.11.0",
13
+ "@o3co/auth-provider-webauthn": "0.11.0"
13
14
  }