@o3co/create-auth-provider 0.17.0 → 0.18.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/README.md +2 -198
- package/main.js +4 -0
- package/package.json +8 -30
- package/README.ja.md +0 -200
- package/dist/cli.d.mts +0 -3
- package/dist/cli.d.mts.map +0 -1
- package/dist/cli.mjs +0 -18
- package/dist/index.d.mts +0 -48
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -325
- package/dist/internal/template-filter.d.mts +0 -14
- package/dist/internal/template-filter.d.mts.map +0 -1
- package/dist/internal/template-filter.mjs +0 -42
- package/templates/standalone/.dockerignore +0 -23
- package/templates/standalone/.env.example +0 -152
- package/templates/standalone/Dockerfile +0 -125
- package/templates/standalone/Makefile +0 -12
- package/templates/standalone/README.ja.md +0 -912
- package/templates/standalone/README.md +0 -1536
- package/templates/standalone/config/application.conf +0 -351
- package/templates/standalone/config/clients.yaml +0 -4
- package/templates/standalone/config/clients.yaml.example +0 -124
- package/templates/standalone/config/development.conf +0 -17
- package/templates/standalone/config/mailpit.conf +0 -13
- package/templates/standalone/config/production.conf +0 -15
- package/templates/standalone/config/reference.conf +0 -427
- package/templates/standalone/docker-compose.mailpit.yml +0 -39
- package/templates/standalone/docker-compose.production.yml +0 -229
- package/templates/standalone/docker-compose.test.yml +0 -16
- package/templates/standalone/docker-compose.yml +0 -67
- package/templates/standalone/gitignore +0 -23
- package/templates/standalone/package.json +0 -56
- package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
- package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
- package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
- package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
- package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
- package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
- package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
- package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
- package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
- package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
- package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
- package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
- package/templates/standalone/src/__tests__/listen.test.mts +0 -121
- package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
- package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
- package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
- package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
- package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
- package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
- package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
- package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
- package/templates/standalone/src/__tests__/sections.test.mts +0 -516
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
- package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
- package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
- package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
- package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
- package/templates/standalone/src/adapters.mts +0 -162
- package/templates/standalone/src/app.mts +0 -127
- package/templates/standalone/src/bootRefusal.mts +0 -117
- package/templates/standalone/src/buildModules.mts +0 -358
- package/templates/standalone/src/configPath.mts +0 -487
- package/templates/standalone/src/listen.mts +0 -60
- package/templates/standalone/src/logger.mts +0 -80
- package/templates/standalone/src/metrics.mts +0 -167
- package/templates/standalone/src/mfaSwitch.mts +0 -284
- package/templates/standalone/src/modules.mts +0 -705
- package/templates/standalone/src/rootRenames.mts +0 -95
- package/templates/standalone/src/routes.mts +0 -89
- package/templates/standalone/src/sections.mts +0 -378
- package/templates/standalone/src/securityHeaders.mts +0 -36
- package/templates/standalone/src/shutdown.mts +0 -213
- package/templates/standalone/tests/Makefile +0 -5
- package/templates/standalone/tests/index.test.js +0 -283
- package/templates/standalone/tests/package-lock.json +0 -1617
- package/templates/standalone/tests/package.json +0 -16
- package/templates/standalone/tests/vitest.config.mjs +0 -26
- package/templates/standalone/tsconfig.json +0 -31
- package/templates/standalone/vitest.config.mts +0 -31
- package/templates/standalone/vitest.supertest-loopback.mts +0 -278
- package/templates/versions.json +0 -20
|
@@ -1,213 +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
|
-
|
|
17
|
-
/*
|
|
18
|
-
* Graceful shutdown for the scaffolded server, kept in the template so that
|
|
19
|
-
* "does SIGTERM wait for in-flight requests, and for how long?" is answerable
|
|
20
|
-
* from the code you deploy. See the template README, "Shutdown guarantees".
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import type { Server } from "node:http";
|
|
24
|
-
import { type Logger, loggableError } from "@o3co/auth-provider-core";
|
|
25
|
-
|
|
26
|
-
/** Options for {@link installGracefulShutdown}. */
|
|
27
|
-
export interface GracefulShutdownOptions {
|
|
28
|
-
readonly logger: Logger;
|
|
29
|
-
/** Reverse-topological component cleanup — normally `handle.dispose()`. */
|
|
30
|
-
readonly cleanup?: () => void | Promise<void>;
|
|
31
|
-
/** How long in-flight requests get before connections are cut. Default 10s. */
|
|
32
|
-
readonly drainTimeoutMs?: number;
|
|
33
|
-
/**
|
|
34
|
-
* How long `cleanup` gets before the shutdown gives up on it, unless
|
|
35
|
-
* `cleanupAllowanceMs` is longer. Defaults to `drainTimeoutMs`, so the
|
|
36
|
-
* worst-case shutdown is the two budgets in sequence — size both against
|
|
37
|
-
* the orchestrator's grace period, not one.
|
|
38
|
-
*/
|
|
39
|
-
readonly cleanupTimeoutMs?: number;
|
|
40
|
-
/**
|
|
41
|
-
* The least `cleanup` gets, read when the signal arrives: normally
|
|
42
|
-
* `() => handle.cleanupAllowanceMs`, the longest tail a module registered
|
|
43
|
-
* its cleanup with. Absent, unreadable, or not a whole number of
|
|
44
|
-
* milliseconds from 1 to 2147483647, the budget is `cleanupTimeoutMs` alone.
|
|
45
|
-
*/
|
|
46
|
-
readonly cleanupAllowanceMs?: () => number | undefined;
|
|
47
|
-
/** Injected in tests; defaults to {@link deferExit}. */
|
|
48
|
-
readonly exit?: (code: number) => void;
|
|
49
|
-
/** Injected in tests; defaults to `process.on`. */
|
|
50
|
-
readonly onSignal?: (signal: NodeJS.Signals, handler: () => void) => void;
|
|
51
|
-
/** Injected in tests; defaults to `process.removeListener`. */
|
|
52
|
-
readonly offSignal?: (signal: NodeJS.Signals, handler: () => void) => void;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Exit after yielding the loop once.
|
|
57
|
-
*
|
|
58
|
-
* The scaffolded app logs NDJSON through pino, whose default destination is not
|
|
59
|
-
* synchronous, so calling `process.exit` in the same tick as the last
|
|
60
|
-
* `logger.error` can drop exactly the lines that say why the shutdown failed.
|
|
61
|
-
* One turn is a flush window, not a guarantee: a deployment that needs
|
|
62
|
-
* certainty should pass an `exit` that flushes its own transport first.
|
|
63
|
-
*/
|
|
64
|
-
export function deferExit(code: number, exitProcess: (code: number) => void = process.exit): void {
|
|
65
|
-
setImmediate(() => exitProcess(code));
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const DEFAULT_DRAIN_TIMEOUT_MS = 10_000;
|
|
69
|
-
const SIGNALS: readonly NodeJS.Signals[] = ["SIGTERM", "SIGINT"];
|
|
70
|
-
|
|
71
|
-
/** The longest delay a timer takes; a larger one, or NaN, fires after about a millisecond. */
|
|
72
|
-
const MAX_TIMER_MS = 2_147_483_647;
|
|
73
|
-
|
|
74
|
-
/** What `read` reports, when it is a delay a timer can wait; otherwise nothing. */
|
|
75
|
-
function allowanceFrom(read: (() => number | undefined) | undefined): number | undefined {
|
|
76
|
-
let allowance: unknown;
|
|
77
|
-
try {
|
|
78
|
-
allowance = read?.();
|
|
79
|
-
} catch {
|
|
80
|
-
return undefined;
|
|
81
|
-
}
|
|
82
|
-
return typeof allowance === "number" &&
|
|
83
|
-
Number.isInteger(allowance) &&
|
|
84
|
-
allowance >= 1 &&
|
|
85
|
-
allowance <= MAX_TIMER_MS
|
|
86
|
-
? allowance
|
|
87
|
-
: undefined;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Install the graceful shutdown on `server`. The guarantees (a deadline on the
|
|
92
|
-
* drain, a non-zero exit past it, a bounded `cleanup` that never wedges the
|
|
93
|
-
* process), the log line of each stage and how to size `drainTimeoutMs` plus
|
|
94
|
-
* `cleanupTimeoutMs` below the orchestrator's kill grace period are in the
|
|
95
|
-
* template README, "Shutdown guarantees". Beyond them: a `close` that fails is
|
|
96
|
-
* not reported as a clean drain, and exits non-zero.
|
|
97
|
-
*/
|
|
98
|
-
export function installGracefulShutdown(server: Server, options: GracefulShutdownOptions): void {
|
|
99
|
-
const {
|
|
100
|
-
logger,
|
|
101
|
-
cleanup,
|
|
102
|
-
drainTimeoutMs = DEFAULT_DRAIN_TIMEOUT_MS,
|
|
103
|
-
exit = deferExit,
|
|
104
|
-
onSignal = (signal, handler): void => {
|
|
105
|
-
process.on(signal, handler);
|
|
106
|
-
},
|
|
107
|
-
offSignal = (signal, handler): void => {
|
|
108
|
-
process.removeListener(signal, handler);
|
|
109
|
-
},
|
|
110
|
-
} = options;
|
|
111
|
-
|
|
112
|
-
// Settled when the signal arrives, with the allowance as it stands then.
|
|
113
|
-
let cleanupTimeoutMs = options.cleanupTimeoutMs ?? drainTimeoutMs;
|
|
114
|
-
|
|
115
|
-
let shuttingDown = false;
|
|
116
|
-
let finished = false;
|
|
117
|
-
|
|
118
|
-
/** Sentinel so a timed-out cleanup is reported as that, not as a throw. */
|
|
119
|
-
const CLEANUP_TIMED_OUT = Symbol("cleanup-timed-out");
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Await `cleanup`, but not forever. `cleanup()` is invoked inside the async
|
|
123
|
-
* wrapper so a synchronous throw lands in the same rejection path as an
|
|
124
|
-
* async one.
|
|
125
|
-
*/
|
|
126
|
-
const runCleanup = async (): Promise<typeof CLEANUP_TIMED_OUT | undefined> => {
|
|
127
|
-
if (!cleanup) return;
|
|
128
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
129
|
-
try {
|
|
130
|
-
return await Promise.race([
|
|
131
|
-
(async (): Promise<undefined> => {
|
|
132
|
-
await cleanup();
|
|
133
|
-
return undefined;
|
|
134
|
-
})(),
|
|
135
|
-
new Promise<typeof CLEANUP_TIMED_OUT>((resolve) => {
|
|
136
|
-
timer = setTimeout(() => resolve(CLEANUP_TIMED_OUT), cleanupTimeoutMs);
|
|
137
|
-
timer.unref?.();
|
|
138
|
-
}),
|
|
139
|
-
]);
|
|
140
|
-
} finally {
|
|
141
|
-
if (timer) clearTimeout(timer);
|
|
142
|
-
}
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
/** Run `cleanup` and exit. Called by whichever of drain / deadline wins. */
|
|
146
|
-
const finish = async (code: number, reason: string): Promise<void> => {
|
|
147
|
-
if (finished) return;
|
|
148
|
-
finished = true;
|
|
149
|
-
let exitCode = code;
|
|
150
|
-
// `reason` names whatever decided the exit code, so the line an operator
|
|
151
|
-
// alerts on cannot say "drained" next to a non-zero code. The drain
|
|
152
|
-
// outcome keeps its own key rather than being overwritten — both facts
|
|
153
|
-
// are wanted, and a stable shape is what makes the line queryable.
|
|
154
|
-
let outcome = reason;
|
|
155
|
-
try {
|
|
156
|
-
if ((await runCleanup()) === CLEANUP_TIMED_OUT) {
|
|
157
|
-
logger.error({ cleanupTimeoutMs }, "shutdown_cleanup_timed_out");
|
|
158
|
-
exitCode = 1;
|
|
159
|
-
outcome = "cleanup-timeout";
|
|
160
|
-
}
|
|
161
|
-
} catch (err) {
|
|
162
|
-
// Through the app logger, not `console.error`: a shutdown that
|
|
163
|
-
// failed to release its Redis connections is exactly the line an
|
|
164
|
-
// operator needs to find later, and a bare write is the one their
|
|
165
|
-
// pipeline drops. The projection, not the error: `dispose()`
|
|
166
|
-
// rejects with every cleanup's own error on `errors`, a store's
|
|
167
|
-
// write — and what it wrote — among them.
|
|
168
|
-
logger.error({ err: loggableError(err) }, "shutdown_cleanup_failed");
|
|
169
|
-
exitCode = 1;
|
|
170
|
-
outcome = "cleanup-failed";
|
|
171
|
-
}
|
|
172
|
-
logger.info({ reason: outcome, drain: reason, exitCode }, "shutdown_complete");
|
|
173
|
-
exit(exitCode);
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
const handler = (): void => {
|
|
177
|
-
if (shuttingDown) return;
|
|
178
|
-
shuttingDown = true;
|
|
179
|
-
for (const signal of SIGNALS) offSignal(signal, handler);
|
|
180
|
-
cleanupTimeoutMs = Math.max(cleanupTimeoutMs, allowanceFrom(options.cleanupAllowanceMs) ?? 0);
|
|
181
|
-
logger.info({ drainTimeoutMs, cleanupTimeoutMs }, "shutdown_draining");
|
|
182
|
-
|
|
183
|
-
const deadline = setTimeout(() => {
|
|
184
|
-
logger.error({ drainTimeoutMs }, "shutdown_drain_deadline_exceeded");
|
|
185
|
-
server.closeAllConnections();
|
|
186
|
-
void finish(1, "drain-timeout");
|
|
187
|
-
}, drainTimeoutMs);
|
|
188
|
-
// The deadline must not be what keeps the process alive once the drain
|
|
189
|
-
// has already finished.
|
|
190
|
-
deadline.unref?.();
|
|
191
|
-
|
|
192
|
-
server.close((err) => {
|
|
193
|
-
clearTimeout(deadline);
|
|
194
|
-
if (err) {
|
|
195
|
-
// `close` reports through its callback — "Server is not running"
|
|
196
|
-
// is the common one, but any listener teardown failure lands
|
|
197
|
-
// here. Reporting "drained" and exiting 0 on it would tell an
|
|
198
|
-
// orchestrator the shutdown went cleanly when the listener did
|
|
199
|
-
// not actually come down.
|
|
200
|
-
logger.error({ err: loggableError(err) }, "shutdown_server_close_failed");
|
|
201
|
-
void finish(1, "close-failed");
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
void finish(0, "drained");
|
|
205
|
-
});
|
|
206
|
-
// Idle keep-alive sockets have no request behind them, so nothing is
|
|
207
|
-
// lost by releasing them — and without this a quiet server waits out
|
|
208
|
-
// the whole deadline for connections that will never send anything.
|
|
209
|
-
server.closeIdleConnections();
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
for (const signal of SIGNALS) onSignal(signal, handler);
|
|
213
|
-
}
|
|
@@ -1,283 +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
|
-
|
|
17
|
-
import axios from "axios";
|
|
18
|
-
import jwt from "jsonwebtoken";
|
|
19
|
-
import { describe, expect, it } from "vitest";
|
|
20
|
-
|
|
21
|
-
const BASE_URL = process.env.API_BASE_URL || "http://localhost:3000";
|
|
22
|
-
const JWT_SECRET = process.env.OAUTH_JWT_SECRET || "your-token-secret";
|
|
23
|
-
|
|
24
|
-
const client = axios.create({
|
|
25
|
-
baseURL: BASE_URL,
|
|
26
|
-
withCredentials: true,
|
|
27
|
-
validateStatus: () => true,
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
const _extractCookies = (res) => (res.headers["set-cookie"] ?? []).join("; ");
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* The server's own origin, as a browser would report it.
|
|
34
|
-
*
|
|
35
|
-
* `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
|
-
|
|
49
|
-
describe("POST /oauth/introspect", () => {
|
|
50
|
-
// Helper: self-introspect sends the token as both Bearer credential and body parameter
|
|
51
|
-
const selfIntrospect = (token, contentType = "application/json") => {
|
|
52
|
-
const headers = {
|
|
53
|
-
Authorization: `Bearer ${token}`,
|
|
54
|
-
"Content-Type": contentType,
|
|
55
|
-
};
|
|
56
|
-
const data =
|
|
57
|
-
contentType === "application/x-www-form-urlencoded"
|
|
58
|
-
? `token=${encodeURIComponent(token)}`
|
|
59
|
-
: { token };
|
|
60
|
-
return client.post("/oauth/introspect", data, { headers });
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
it("returns 401 when no authentication is provided", async () => {
|
|
64
|
-
const res = await client.post("/oauth/introspect", {});
|
|
65
|
-
expect(res.status).toBe(401);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
describe("self-introspect (JSON)", () => {
|
|
69
|
-
it("returns active: true for a valid JWT", async () => {
|
|
70
|
-
const token = jwt.sign({ user: { id: 1 }, scopes: ["read"] }, JWT_SECRET, {
|
|
71
|
-
expiresIn: 60,
|
|
72
|
-
});
|
|
73
|
-
const res = await selfIntrospect(token);
|
|
74
|
-
expect(res.status).toBe(200);
|
|
75
|
-
expect(res.data.active).toBe(true);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("returns scope as space-separated string per RFC 7662", async () => {
|
|
79
|
-
const token = jwt.sign({ user: { id: 1 }, scopes: ["read", "write"] }, JWT_SECRET, {
|
|
80
|
-
expiresIn: 60,
|
|
81
|
-
});
|
|
82
|
-
const res = await selfIntrospect(token);
|
|
83
|
-
expect(res.status).toBe(200);
|
|
84
|
-
expect(res.data.scope).toBe("read write");
|
|
85
|
-
expect(res.data.scopes).toBeUndefined();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("returns 401 for an invalid JWT", async () => {
|
|
89
|
-
const res = await selfIntrospect("invalid.token.here");
|
|
90
|
-
expect(res.status).toBe(401);
|
|
91
|
-
expect(res.data.active).toBe(false);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("returns 401 for an expired JWT", async () => {
|
|
95
|
-
const token = jwt.sign({ user: { id: 1 } }, JWT_SECRET, { expiresIn: -1 });
|
|
96
|
-
const res = await selfIntrospect(token);
|
|
97
|
-
expect(res.status).toBe(401);
|
|
98
|
-
expect(res.data.active).toBe(false);
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
describe("self-introspect (application/x-www-form-urlencoded)", () => {
|
|
103
|
-
it("returns active: true for a valid JWT", async () => {
|
|
104
|
-
const token = jwt.sign({ user: { id: 1 }, scopes: ["read"] }, JWT_SECRET, {
|
|
105
|
-
expiresIn: 60,
|
|
106
|
-
});
|
|
107
|
-
const res = await selfIntrospect(token, "application/x-www-form-urlencoded");
|
|
108
|
-
expect(res.status).toBe(200);
|
|
109
|
-
expect(res.data.active).toBe(true);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("returns 401 for an invalid JWT", async () => {
|
|
113
|
-
const res = await selfIntrospect("invalid.token.here", "application/x-www-form-urlencoded");
|
|
114
|
-
expect(res.status).toBe(401);
|
|
115
|
-
expect(res.data.active).toBe(false);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("returns 401 for an expired JWT", async () => {
|
|
119
|
-
const token = jwt.sign({ user: { id: 1 } }, JWT_SECRET, { expiresIn: -1 });
|
|
120
|
-
const res = await selfIntrospect(token, "application/x-www-form-urlencoded");
|
|
121
|
-
expect(res.status).toBe(401);
|
|
122
|
-
expect(res.data.active).toBe(false);
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
describe("POST /session/login", () => {
|
|
128
|
-
it("returns 400 when no credentials are provided", async () => {
|
|
129
|
-
const res = await sessionPost("/session/login", {});
|
|
130
|
-
expect(res.status).toBe(400);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("returns 400 when redirect_to has an invalid scheme", async () => {
|
|
134
|
-
const res = await sessionPost("/session/login", {
|
|
135
|
-
username: "testuser",
|
|
136
|
-
password: "testpass",
|
|
137
|
-
redirect_to: "javascript:alert(1)",
|
|
138
|
-
});
|
|
139
|
-
expect(res.status).toBe(400);
|
|
140
|
-
expect(res.data.error).toBe("invalid_redirect");
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it("returns 400 when redirect_to is not a valid URL", async () => {
|
|
144
|
-
const res = await sessionPost("/session/login", {
|
|
145
|
-
username: "testuser",
|
|
146
|
-
password: "testpass",
|
|
147
|
-
redirect_to: "not-a-url",
|
|
148
|
-
});
|
|
149
|
-
expect(res.status).toBe(400);
|
|
150
|
-
expect(res.data.error).toBe("invalid_redirect");
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("returns 400 when redirect_to exceeds 2048 characters", async () => {
|
|
154
|
-
const res = await sessionPost("/session/login", {
|
|
155
|
-
username: "testuser",
|
|
156
|
-
password: "testpass",
|
|
157
|
-
redirect_to: `https://example.com/${"a".repeat(2048)}`,
|
|
158
|
-
});
|
|
159
|
-
expect(res.status).toBe(400);
|
|
160
|
-
expect(res.data.error).toBe("invalid_redirect");
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("accepts a valid redirect_to without error", async () => {
|
|
164
|
-
// redirect_to validation runs before authentication, so even with
|
|
165
|
-
// invalid credentials, a valid redirect_to should not cause a 400
|
|
166
|
-
// with error: "invalid_redirect". It should fail with 401 (auth failure).
|
|
167
|
-
const res = await sessionPost("/session/login", {
|
|
168
|
-
username: "testuser",
|
|
169
|
-
password: "testpass",
|
|
170
|
-
redirect_to: "http://localhost:3000/oauth/authorize",
|
|
171
|
-
});
|
|
172
|
-
expect(res.status).not.toBe(400);
|
|
173
|
-
expect(res.data.error).not.toBe("invalid_redirect");
|
|
174
|
-
});
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
describe("CSRF on the state-changing session routes", () => {
|
|
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
|
-
|
|
224
|
-
describe("POST /oauth/token", () => {
|
|
225
|
-
it("returns 401 for grant_type=session without an authenticated session", async () => {
|
|
226
|
-
const res = await client.post("/oauth/token", "grant_type=session", {
|
|
227
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
228
|
-
});
|
|
229
|
-
expect(res.status).toBe(401);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it("returns 400 for an unsupported grant_type", async () => {
|
|
233
|
-
const res = await client.post("/oauth/token", "grant_type=unknown", {
|
|
234
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
235
|
-
});
|
|
236
|
-
expect(res.status).toBe(400);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it("returns 400 for grant_type=authorization_code without a code", async () => {
|
|
240
|
-
const res = await client.post("/oauth/token", "grant_type=authorization_code", {
|
|
241
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
242
|
-
});
|
|
243
|
-
expect(res.status).toBe(400);
|
|
244
|
-
});
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
describe("GET /oauth/authorize", () => {
|
|
248
|
-
it("redirects to login with redirect_to when the session is not authenticated", async () => {
|
|
249
|
-
const res = await client.get(
|
|
250
|
-
"/oauth/authorize?response_type=code&client_id=test&redirect_uri=http://localhost:9999/callback",
|
|
251
|
-
{ maxRedirects: 0 },
|
|
252
|
-
);
|
|
253
|
-
expect(res.status).toBe(302);
|
|
254
|
-
const location = res.headers.location;
|
|
255
|
-
expect(location).toContain("login");
|
|
256
|
-
expect(location).toContain("redirect_to=");
|
|
257
|
-
expect(location).not.toContain("redirect_uri=");
|
|
258
|
-
// Verify the authorize URL is properly encoded (? and & should be escaped)
|
|
259
|
-
expect(location).toContain(encodeURIComponent("response_type=code"));
|
|
260
|
-
});
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
describe("GET /session/oauth/federation/google", () => {
|
|
264
|
-
// NOTE: Development config has Google federation disabled (google.enabled: false).
|
|
265
|
-
// No federation is registered under that name, so the route answers 404 before
|
|
266
|
-
// redirect_to is validated, and redirect_to validation tests cannot run in this
|
|
267
|
-
// environment. Validation logic is identical to POST /session/login and covered
|
|
268
|
-
// by those tests.
|
|
269
|
-
|
|
270
|
-
it("returns 404 when Google federation is disabled", async () => {
|
|
271
|
-
const res = await client.get(
|
|
272
|
-
"/session/oauth/federation/google?redirect_to=https://localhost:3001",
|
|
273
|
-
);
|
|
274
|
-
expect(res.status).toBe(404);
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
it("returns 404 even with invalid redirect_to when Google is disabled", async () => {
|
|
278
|
-
const res = await client.get(
|
|
279
|
-
"/session/oauth/federation/google?redirect_to=javascript:alert(1)",
|
|
280
|
-
);
|
|
281
|
-
expect(res.status).toBe(404);
|
|
282
|
-
});
|
|
283
|
-
});
|