@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.
- package/dist/index.d.mts +20 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +110 -7
- package/package.json +4 -4
- package/templates/standalone/.env.example +49 -5
- package/templates/standalone/Dockerfile +59 -16
- package/templates/standalone/README.ja.md +92 -13
- package/templates/standalone/README.md +358 -18
- package/templates/standalone/config/application.conf +132 -4
- package/templates/standalone/config/clients.yaml.example +56 -2
- package/templates/standalone/config/production.conf +9 -3
- package/templates/standalone/docker-compose.production.yml +132 -0
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/gitignore +21 -0
- package/templates/standalone/package.json +15 -20
- package/templates/standalone/src/__tests__/audit-sink.test.mts +343 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +423 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +114 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +231 -0
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +76 -1
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +117 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +244 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +213 -11
- package/templates/standalone/src/__tests__/terminal-error.test.mts +96 -0
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +117 -7
- package/templates/standalone/src/app.mts +78 -10
- package/templates/standalone/src/buildModules.mts +43 -1
- package/templates/standalone/src/configPath.mts +2 -2
- package/templates/standalone/src/logger.mts +90 -2
- package/templates/standalone/src/metrics.mts +190 -0
- package/templates/standalone/src/modules.mts +319 -33
- package/templates/standalone/src/shutdown.mts +162 -0
- package/templates/standalone/src/terminalError.mts +60 -0
- package/templates/standalone/tests/index.test.js +69 -5
- package/templates/standalone/tsconfig.json +9 -1
- package/templates/standalone/vitest.config.mts +8 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
});
|
package/templates/versions.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"@o3co/auth-provider-core": "0.
|
|
3
|
-
"@o3co/auth-provider-
|
|
4
|
-
"@o3co/auth-provider-
|
|
5
|
-
"@o3co/auth-provider-
|
|
6
|
-
"@o3co/auth-provider-federation-
|
|
7
|
-
"@o3co/auth-provider-
|
|
8
|
-
"@o3co/auth-provider-oauth
|
|
9
|
-
"@o3co/auth-provider-
|
|
10
|
-
"@o3co/auth-provider-
|
|
11
|
-
"@o3co/auth-provider-
|
|
12
|
-
"@o3co/auth-provider-
|
|
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
|
}
|