@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,464 +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
|
-
* A federation grant outlives the session it was agreed through, at both HTTP
|
|
19
|
-
* logout endpoints (ADR 2026-09-17-federation-grants-offline-delegation).
|
|
20
|
-
* Both close the session through core's session lifecycle, whose close work
|
|
21
|
-
* reaches the grant store on no path, by construction. A proof of the helper is not a proof of the endpoints, so
|
|
22
|
-
* this drives both, on the standalone, as a deployment composes them: a grant
|
|
23
|
-
* seeded beside a live browser session, the session's records gone afterwards,
|
|
24
|
-
* the grant and its credential exactly as they were, and `/token` still
|
|
25
|
-
* disclosing.
|
|
26
|
-
*
|
|
27
|
-
* The seeded grant's access token is fresh, so the disclosure is the cached
|
|
28
|
-
* one and no upstream is asked; the OIDC federation the connection names has
|
|
29
|
-
* `discovery = false` and hand-typed endpoints so the deployment boots without
|
|
30
|
-
* a network.
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
import { createHmac } from "node:crypto";
|
|
34
|
-
import { fileURLToPath } from "node:url";
|
|
35
|
-
import {
|
|
36
|
-
type AppConfig,
|
|
37
|
-
createApp,
|
|
38
|
-
createKeyStoreFactory,
|
|
39
|
-
defineModule,
|
|
40
|
-
type FederationTokenStore,
|
|
41
|
-
federationGrantAuthorizationRevision,
|
|
42
|
-
federationGrantIdentityRevision,
|
|
43
|
-
federationsOf,
|
|
44
|
-
InMemoryClientRepository,
|
|
45
|
-
InMemoryUserRepository,
|
|
46
|
-
type MemoryFederationGrantStore,
|
|
47
|
-
memoryRefreshTokenFamilyStoreModule,
|
|
48
|
-
registerBuiltinKeyStores,
|
|
49
|
-
type UserSessionStore,
|
|
50
|
-
} from "@o3co/auth-provider-core";
|
|
51
|
-
import { parseFile } from "@o3co/ts.hocon";
|
|
52
|
-
import express from "express";
|
|
53
|
-
import request from "supertest";
|
|
54
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
55
|
-
import { buildModules } from "../buildModules.mjs";
|
|
56
|
-
import { resolveConfigPaths } from "../configPath.mjs";
|
|
57
|
-
import { templateReference } from "../modules.mjs";
|
|
58
|
-
import { keyStoreSectionSchema } from "../sections.mjs";
|
|
59
|
-
import {
|
|
60
|
-
type BothPhases,
|
|
61
|
-
bothPhasesOf,
|
|
62
|
-
capturedRenames,
|
|
63
|
-
libraryLayers,
|
|
64
|
-
} from "./library-references.fixture.mjs";
|
|
65
|
-
|
|
66
|
-
const DAY = 86_400_000;
|
|
67
|
-
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
68
|
-
|
|
69
|
-
const ISSUER = "https://auth.test";
|
|
70
|
-
const JWT_SECRET = "federation-grants-survive-logout.at-least-32-bytes.ok";
|
|
71
|
-
const UPSTREAM = "https://issuer.example";
|
|
72
|
-
const UPSTREAM_CLIENT_ID = "provider-client";
|
|
73
|
-
const ENCRYPTION_KEY = Buffer.alloc(32, 9).toString("base64");
|
|
74
|
-
|
|
75
|
-
const CLIENT_ID = "worker";
|
|
76
|
-
const CLIENT_SECRET = "worker-secret-value";
|
|
77
|
-
const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
|
|
78
|
-
const USERNAME = "alice";
|
|
79
|
-
const PASSWORD = "correct-horse-battery-staple";
|
|
80
|
-
const SUB = "u-alice";
|
|
81
|
-
|
|
82
|
-
const CONNECTION = "calendar";
|
|
83
|
-
const SCOPES: readonly string[] = ["openid", "offline_access", "calendar.read"];
|
|
84
|
-
const BOUNDARY = "production";
|
|
85
|
-
const GRANT_ID = "g-1";
|
|
86
|
-
const REFRESH_TOKEN = "SENTINEL-refresh-token";
|
|
87
|
-
const ACCESS_TOKEN = "upstream-access-token";
|
|
88
|
-
|
|
89
|
-
const ENV: Readonly<Record<string, string>> = {
|
|
90
|
-
// The logout here is a session without a second factor.
|
|
91
|
-
MFA_MODE: "off",
|
|
92
|
-
KEY_STORE_LOCAL_ALGORITHM: "HS256",
|
|
93
|
-
KEY_STORE_LOCAL_SECRET: JWT_SECRET,
|
|
94
|
-
OAUTH_JWT_ISSUER: ISSUER,
|
|
95
|
-
SESSION_STORE_SECRET: "federation-grants-survive-logout-session.at-least-32-bytes.ok",
|
|
96
|
-
SESSION_STORE_SECURE: "false",
|
|
97
|
-
SESSION_STORE_NAME: "auth.session",
|
|
98
|
-
SESSION_STORE_STORAGE_TYPE: "memory",
|
|
99
|
-
ADAPTERS_USER_REPOSITORY: "yaml",
|
|
100
|
-
REDIS_CLIENTS_URL: "redis://redis.test:6379",
|
|
101
|
-
ADAPTERS_USER_SESSION_STORES: "memory",
|
|
102
|
-
ADAPTERS_RATE_LIMITER: "memory",
|
|
103
|
-
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
104
|
-
ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
|
|
105
|
-
ADAPTERS_REPLAY_SEEN_SET: "memory",
|
|
106
|
-
ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
|
|
107
|
-
ADAPTERS_CONSENT_STORE: "none",
|
|
108
|
-
ADAPTERS_FEDERATION_GRANT_STORE: "memory",
|
|
109
|
-
ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
|
|
110
|
-
FEDERATION_GRANTS_ENABLED: "true",
|
|
111
|
-
FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
|
|
112
|
-
// The connect flow is not driven here, and the bundled repository has no
|
|
113
|
-
// identity lookup; "unsupported" is the declaration a deployment without one
|
|
114
|
-
// makes, and what lets a connection be configured at all.
|
|
115
|
-
FEDERATION_GRANTS_IDENTITY_LOOKUP: "unsupported",
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* The shipped configuration under the environment above, with the two lists
|
|
120
|
-
* that have no environment form written over it: the
|
|
121
|
-
* upstream federation the connection names, and the connection.
|
|
122
|
-
*/
|
|
123
|
-
function resolveConfig(): BothPhases {
|
|
124
|
-
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
125
|
-
const layers = parseFile(envConfPath, { env: ENV })
|
|
126
|
-
.withFallback(parseFile(applicationConfPath, { env: ENV }))
|
|
127
|
-
.withFallback(parseFile(fileURLToPath(templateReference()), { env: ENV }))
|
|
128
|
-
.withFallback(libraryLayers(ENV));
|
|
129
|
-
const config = bothPhasesOf(layers, ENV);
|
|
130
|
-
return {
|
|
131
|
-
...config,
|
|
132
|
-
// What the resolution captured of the renamed variables.
|
|
133
|
-
"renamed-variables": capturedRenames(ENV),
|
|
134
|
-
core: {
|
|
135
|
-
...config.core,
|
|
136
|
-
federations: {
|
|
137
|
-
...federationsOf(config),
|
|
138
|
-
upstream: {
|
|
139
|
-
enabled: true,
|
|
140
|
-
type: "oidc",
|
|
141
|
-
issuer: UPSTREAM,
|
|
142
|
-
clientId: UPSTREAM_CLIENT_ID,
|
|
143
|
-
clientSecret: "provider-secret",
|
|
144
|
-
callbackURL: `${ISSUER}/session/oauth/federation/upstream/callback`,
|
|
145
|
-
scopes: [...SCOPES],
|
|
146
|
-
discovery: false,
|
|
147
|
-
endpoints: {
|
|
148
|
-
authorizationEndpoint: `${UPSTREAM}/authorize`,
|
|
149
|
-
tokenEndpoint: `${UPSTREAM}/token`,
|
|
150
|
-
jwksUri: `${UPSTREAM}/jwks`,
|
|
151
|
-
},
|
|
152
|
-
redirectAllowlist: [],
|
|
153
|
-
},
|
|
154
|
-
},
|
|
155
|
-
},
|
|
156
|
-
"redis-federation-grant-store": {
|
|
157
|
-
...(config["redis-federation-grant-store"] as object | undefined),
|
|
158
|
-
encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
|
|
159
|
-
},
|
|
160
|
-
"federation-grants": {
|
|
161
|
-
...config["federation-grants"],
|
|
162
|
-
connections: {
|
|
163
|
-
[CONNECTION]: {
|
|
164
|
-
federation: "upstream",
|
|
165
|
-
scopes: [...SCOPES],
|
|
166
|
-
boundary: BOUNDARY,
|
|
167
|
-
maxAccessTokenLifetime: 3600,
|
|
168
|
-
callbackURL: `${ISSUER}/session/federation-grants/callback/${CONNECTION}`,
|
|
169
|
-
},
|
|
170
|
-
},
|
|
171
|
-
},
|
|
172
|
-
} as BothPhases;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const testRepositoriesModule = defineModule({
|
|
176
|
-
name: "test:repositories",
|
|
177
|
-
provides: {
|
|
178
|
-
clientRepository: () =>
|
|
179
|
-
new InMemoryClientRepository(
|
|
180
|
-
new Map([
|
|
181
|
-
[
|
|
182
|
-
CLIENT_ID,
|
|
183
|
-
{
|
|
184
|
-
tokenEndpointAuthMethod: "client_secret_basic",
|
|
185
|
-
clientSecret: CLIENT_SECRET,
|
|
186
|
-
allowedRedirectUris: [],
|
|
187
|
-
allowedScopes: ["openid", "profile"],
|
|
188
|
-
allowedAudiences: [],
|
|
189
|
-
allowedGrantTypes: ["session"],
|
|
190
|
-
backchannelLogoutSessionRequired: true,
|
|
191
|
-
frontchannelLogoutSessionRequired: true,
|
|
192
|
-
allowedAzpForFederationToken: false,
|
|
193
|
-
allowedFederationGrantConnections: [CONNECTION],
|
|
194
|
-
},
|
|
195
|
-
],
|
|
196
|
-
]),
|
|
197
|
-
),
|
|
198
|
-
userRepository: () =>
|
|
199
|
-
new InMemoryUserRepository(
|
|
200
|
-
new Map([[USERNAME, { id: SUB, password: PASSWORD, email: "alice@example.com" }]]),
|
|
201
|
-
),
|
|
202
|
-
} as never,
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
// The template's `key-store` section, read by its own schema, without the
|
|
206
|
-
// section's relocation bridge.
|
|
207
|
-
const testKeyStoreModule = defineModule({
|
|
208
|
-
name: "key-store",
|
|
209
|
-
section: { schema: keyStoreSectionSchema },
|
|
210
|
-
provides: {
|
|
211
|
-
keyStore: async ({ section }) => {
|
|
212
|
-
const factory = createKeyStoreFactory();
|
|
213
|
-
registerBuiltinKeyStores(factory);
|
|
214
|
-
return factory.create({
|
|
215
|
-
type: "local",
|
|
216
|
-
...(section.local ?? {}),
|
|
217
|
-
});
|
|
218
|
-
},
|
|
219
|
-
},
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
/** The grant as the connect flow would have left it, a fresh upstream token in hand. */
|
|
223
|
-
async function seedGrant(store: MemoryFederationGrantStore): Promise<void> {
|
|
224
|
-
const at = new Date();
|
|
225
|
-
// The revisions as `connections.mts` resolves them from the configuration
|
|
226
|
-
// above: the federation's issuer and client, the connection's scopes and
|
|
227
|
-
// boundary, no resource, no authorization parameters.
|
|
228
|
-
const resolved = {
|
|
229
|
-
upstreamIssuer: UPSTREAM,
|
|
230
|
-
upstreamClientId: UPSTREAM_CLIENT_ID,
|
|
231
|
-
scopes: SCOPES,
|
|
232
|
-
boundary: BOUNDARY,
|
|
233
|
-
authorizationParams: {},
|
|
234
|
-
};
|
|
235
|
-
await store.createPending({
|
|
236
|
-
id: GRANT_ID,
|
|
237
|
-
subject: SUB,
|
|
238
|
-
clientId: CLIENT_ID,
|
|
239
|
-
connection: CONNECTION,
|
|
240
|
-
intent: { handle: "h", expiresAt: new Date(at.getTime() + 600_000) },
|
|
241
|
-
now: at,
|
|
242
|
-
});
|
|
243
|
-
const written = await store.activate({
|
|
244
|
-
grantId: GRANT_ID,
|
|
245
|
-
intentHandle: "h",
|
|
246
|
-
authorization: {
|
|
247
|
-
identityRevision: federationGrantIdentityRevision(resolved),
|
|
248
|
-
authorizationRevision: federationGrantAuthorizationRevision(resolved),
|
|
249
|
-
upstream: { issuer: UPSTREAM, subject: "upstream-alice" },
|
|
250
|
-
scopes: [...SCOPES],
|
|
251
|
-
consent: { at, sid: "sid-consent", scopes: [...SCOPES] },
|
|
252
|
-
authorizedAt: at,
|
|
253
|
-
expiresAt: new Date(at.getTime() + 30 * DAY),
|
|
254
|
-
resource: undefined,
|
|
255
|
-
},
|
|
256
|
-
credentials: {
|
|
257
|
-
refreshToken: REFRESH_TOKEN,
|
|
258
|
-
accessToken: {
|
|
259
|
-
value: ACCESS_TOKEN,
|
|
260
|
-
tokenType: "Bearer",
|
|
261
|
-
obtainedAt: at,
|
|
262
|
-
issuedLifetime: 3600,
|
|
263
|
-
effectiveExpiresAt: new Date(at.getTime() + 3_600_000),
|
|
264
|
-
scopes: [...SCOPES],
|
|
265
|
-
},
|
|
266
|
-
},
|
|
267
|
-
now: at,
|
|
268
|
-
});
|
|
269
|
-
if (!written.ok) throw new Error("fixture: the grant was not activated");
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const base64url = (value: unknown): string =>
|
|
273
|
-
Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* An id_token for the session, signed with the deployment's own HS256 key, as
|
|
277
|
-
* the RP would present it: `/oauth/logout` reads `sid` from the hint and from
|
|
278
|
-
* nothing else.
|
|
279
|
-
*/
|
|
280
|
-
function idTokenHint(sid: string): string {
|
|
281
|
-
const header = base64url({ alg: "HS256", kid: "v0", typ: "JWT" });
|
|
282
|
-
const now = Math.floor(Date.now() / 1000);
|
|
283
|
-
const payload = base64url({
|
|
284
|
-
iss: ISSUER,
|
|
285
|
-
sub: SUB,
|
|
286
|
-
aud: CLIENT_ID,
|
|
287
|
-
sid,
|
|
288
|
-
iat: now,
|
|
289
|
-
exp: now + 3600,
|
|
290
|
-
});
|
|
291
|
-
const signature = createHmac("sha256", JWT_SECRET)
|
|
292
|
-
.update(`${header}.${payload}`)
|
|
293
|
-
.digest("base64url");
|
|
294
|
-
return `${header}.${payload}.${signature}`;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const claimsOf = (jwt: string): Record<string, unknown> =>
|
|
298
|
-
JSON.parse(Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8")) as Record<
|
|
299
|
-
string,
|
|
300
|
-
unknown
|
|
301
|
-
>;
|
|
302
|
-
|
|
303
|
-
describe("a grant outlives the browser session at both logout endpoints", () => {
|
|
304
|
-
let handleRef: Awaited<ReturnType<typeof createApp>> | undefined;
|
|
305
|
-
|
|
306
|
-
afterEach(async () => {
|
|
307
|
-
await handleRef?.dispose();
|
|
308
|
-
handleRef = undefined;
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
async function boot() {
|
|
312
|
-
const config = resolveConfig();
|
|
313
|
-
const handle = await createApp({
|
|
314
|
-
modules: buildModules(config, {
|
|
315
|
-
keyStoreModule: testKeyStoreModule,
|
|
316
|
-
repositoriesModule: testRepositoriesModule,
|
|
317
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
318
|
-
}),
|
|
319
|
-
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
320
|
-
});
|
|
321
|
-
handleRef = handle;
|
|
322
|
-
const components = handle.components as {
|
|
323
|
-
federationGrantStore: MemoryFederationGrantStore;
|
|
324
|
-
userSessionStore: UserSessionStore;
|
|
325
|
-
federationTokenStore: FederationTokenStore;
|
|
326
|
-
};
|
|
327
|
-
await seedGrant(components.federationGrantStore);
|
|
328
|
-
const app = express();
|
|
329
|
-
app.use(handle.router);
|
|
330
|
-
return { app, config, ...components };
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/** The real browser half: a CSRF pair, a login, and the cookies plus the token the login reissued. */
|
|
334
|
-
async function login(app: express.Express, config: AppConfig) {
|
|
335
|
-
const csrfRes = await request(app).get("/session/csrf");
|
|
336
|
-
expect(csrfRes.status).toBe(200);
|
|
337
|
-
const csrfToken = csrfRes.body.csrf_token as string;
|
|
338
|
-
const headerName = csrfRes.body.header_name as string;
|
|
339
|
-
const csrfCookies = csrfRes.headers["set-cookie"] as unknown as string[];
|
|
340
|
-
|
|
341
|
-
const loginRes = await request(app)
|
|
342
|
-
.post("/session/login")
|
|
343
|
-
.set("Cookie", csrfCookies)
|
|
344
|
-
.set(headerName, csrfToken)
|
|
345
|
-
.type("form")
|
|
346
|
-
.send({ username: USERNAME, password: PASSWORD });
|
|
347
|
-
expect(loginRes.status).toBe(200);
|
|
348
|
-
|
|
349
|
-
const cookies = loginRes.headers["set-cookie"] as unknown as string[];
|
|
350
|
-
const csrfCookiePrefix = `${(config["session-store"] as { name: string }).name}.csrf=`;
|
|
351
|
-
const reissued = cookies
|
|
352
|
-
.find((c) => c.startsWith(csrfCookiePrefix))
|
|
353
|
-
?.slice(csrfCookiePrefix.length)
|
|
354
|
-
.split(";")[0];
|
|
355
|
-
expect(reissued).toBeDefined();
|
|
356
|
-
return { cookies, csrfToken: decodeURIComponent(reissued as string), headerName };
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/** The session's `sid`, read off the access token the session grant mints for it. */
|
|
360
|
-
async function sidOf(app: express.Express, cookies: string[]): Promise<string> {
|
|
361
|
-
const res = await request(app)
|
|
362
|
-
.post("/oauth/token")
|
|
363
|
-
.set("Authorization", BASIC)
|
|
364
|
-
.set("Cookie", cookies)
|
|
365
|
-
.type("form")
|
|
366
|
-
.send({ grant_type: "session" });
|
|
367
|
-
expect(res.status).toBe(200);
|
|
368
|
-
const sid = claimsOf(res.body.access_token as string).sid;
|
|
369
|
-
expect(typeof sid).toBe("string");
|
|
370
|
-
return sid as string;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const disclose = (app: express.Express) =>
|
|
374
|
-
request(app)
|
|
375
|
-
.post(`/oauth/federation-grants/${GRANT_ID}/token`)
|
|
376
|
-
.set("Authorization", BASIC)
|
|
377
|
-
.send({ sub: SUB });
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* What a logout has to leave: the record, its credential — the values, not
|
|
381
|
-
* only a readable state, since a rotated or dropped refresh token beside an
|
|
382
|
-
* untouched access token would still read `"ok"` — and a disclosure that
|
|
383
|
-
* still answers.
|
|
384
|
-
*/
|
|
385
|
-
async function expectGrantUntouched(app: express.Express, store: MemoryFederationGrantStore) {
|
|
386
|
-
expect((await store.find(GRANT_ID, new Date()))?.status).toBe("active");
|
|
387
|
-
const opened = await store.open(GRANT_ID, new Date());
|
|
388
|
-
if (opened?.credentials.state !== "ok")
|
|
389
|
-
throw new Error("the grant's credential is not readable");
|
|
390
|
-
expect(opened.credentials.value).toMatchObject({
|
|
391
|
-
refreshToken: REFRESH_TOKEN,
|
|
392
|
-
accessToken: { value: ACCESS_TOKEN },
|
|
393
|
-
});
|
|
394
|
-
const response = await disclose(app);
|
|
395
|
-
expect(response.status).toBe(200);
|
|
396
|
-
expect(response.body.access_token).toBe(ACCESS_TOKEN);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
it("POST /session/logout ends the session's own records and leaves the grant spendable", async () => {
|
|
400
|
-
const { app, config, federationGrantStore, userSessionStore, federationTokenStore } =
|
|
401
|
-
await boot();
|
|
402
|
-
const { cookies, csrfToken, headerName } = await login(app, config);
|
|
403
|
-
const sid = await sidOf(app, cookies);
|
|
404
|
-
// A session-bound upstream token beside the grant, keyed by this
|
|
405
|
-
// session: a record logout must end.
|
|
406
|
-
await federationTokenStore.attach(sid, "upstream", {
|
|
407
|
-
accessToken: "session-bound-upstream-token",
|
|
408
|
-
expiresAt: null,
|
|
409
|
-
// Every field is a required key; this record carries only the
|
|
410
|
-
// access token.
|
|
411
|
-
refreshToken: undefined,
|
|
412
|
-
idToken: undefined,
|
|
413
|
-
tokenType: undefined,
|
|
414
|
-
scope: undefined,
|
|
415
|
-
grantedScope: undefined,
|
|
416
|
-
obtainedAt: undefined,
|
|
417
|
-
});
|
|
418
|
-
expect(await userSessionStore.get(sid)).not.toBeNull();
|
|
419
|
-
await expectGrantUntouched(app, federationGrantStore);
|
|
420
|
-
|
|
421
|
-
const logout = await request(app)
|
|
422
|
-
.post("/session/logout")
|
|
423
|
-
.set("Cookie", cookies)
|
|
424
|
-
.set(headerName, csrfToken);
|
|
425
|
-
expect([200, 204]).toContain(logout.status);
|
|
426
|
-
|
|
427
|
-
// The session-bound records are gone ...
|
|
428
|
-
expect(await userSessionStore.get(sid)).toBeNull();
|
|
429
|
-
expect(await federationTokenStore.get(sid, "upstream")).toBeNull();
|
|
430
|
-
// ... and the grant is not among them.
|
|
431
|
-
await expectGrantUntouched(app, federationGrantStore);
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
it("POST /oauth/logout runs the cascade and leaves the grant spendable", async () => {
|
|
435
|
-
const { app, config, federationGrantStore, userSessionStore, federationTokenStore } =
|
|
436
|
-
await boot();
|
|
437
|
-
const { cookies } = await login(app, config);
|
|
438
|
-
const sid = await sidOf(app, cookies);
|
|
439
|
-
await federationTokenStore.attach(sid, "upstream", {
|
|
440
|
-
accessToken: "session-bound-upstream-token",
|
|
441
|
-
expiresAt: null,
|
|
442
|
-
// Every field is a required key; this record carries only the
|
|
443
|
-
// access token.
|
|
444
|
-
refreshToken: undefined,
|
|
445
|
-
idToken: undefined,
|
|
446
|
-
tokenType: undefined,
|
|
447
|
-
scope: undefined,
|
|
448
|
-
grantedScope: undefined,
|
|
449
|
-
obtainedAt: undefined,
|
|
450
|
-
});
|
|
451
|
-
expect(await userSessionStore.get(sid)).not.toBeNull();
|
|
452
|
-
await expectGrantUntouched(app, federationGrantStore);
|
|
453
|
-
|
|
454
|
-
const logout = await request(app)
|
|
455
|
-
.post("/oauth/logout")
|
|
456
|
-
.type("form")
|
|
457
|
-
.send({ id_token_hint: idTokenHint(sid) });
|
|
458
|
-
expect(logout.status).toBe(200);
|
|
459
|
-
|
|
460
|
-
expect(await userSessionStore.get(sid)).toBeNull();
|
|
461
|
-
expect(await federationTokenStore.get(sid, "upstream")).toBeNull();
|
|
462
|
-
await expectGrantUntouched(app, federationGrantStore);
|
|
463
|
-
});
|
|
464
|
-
});
|
|
@@ -1,141 +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
|
-
* `POST /oauth/federation/:name/token` ends a session's upstream tokens only
|
|
19
|
-
* on the upstream's own verdict that the refresh token is bad (a structured
|
|
20
|
-
* `invalid_grant` under a 4xx), and never while the upstream is down, whatever
|
|
21
|
-
* the body of its 5xx says. An outage is `503`, and the tokens are kept for
|
|
22
|
-
* the retry.
|
|
23
|
-
*
|
|
24
|
-
* Driven through the real Google adapter and its library (openid-client)
|
|
25
|
-
* against a fake Google (`createFakeIdp`), so the errors the route classifies
|
|
26
|
-
* are the ones the library raises: a 4xx whose body names a code is a
|
|
27
|
-
* `ResponseBodyError` carrying `error` and `status`; a 5xx is an
|
|
28
|
-
* `OperationProcessingError` raised over the `Response`, whose body the
|
|
29
|
-
* library does not read.
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
|
|
33
|
-
import request from "supertest";
|
|
34
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
35
|
-
import {
|
|
36
|
-
accessTokenWithFamily,
|
|
37
|
-
GOOGLE_CLIENT_ID,
|
|
38
|
-
type GoogleSession,
|
|
39
|
-
signInLinkedToGoogle,
|
|
40
|
-
} from "./google-session.fixture.mjs";
|
|
41
|
-
|
|
42
|
-
const GOOGLE = {
|
|
43
|
-
issuer: "https://accounts.google.com",
|
|
44
|
-
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
45
|
-
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
|
46
|
-
jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
|
|
47
|
-
userinfoEndpoint: "https://www.googleapis.com/oauth2/v3/userinfo",
|
|
48
|
-
};
|
|
49
|
-
const STORED_REFRESH_TOKEN = "stored-refresh-token";
|
|
50
|
-
|
|
51
|
-
describe("POST /oauth/federation/google/token: an upstream outage never ends the session's Google tokens", () => {
|
|
52
|
-
let session: GoogleSession | undefined;
|
|
53
|
-
|
|
54
|
-
afterEach(async () => {
|
|
55
|
-
await session?.dispose();
|
|
56
|
-
session = undefined;
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Alice's session with Google linked, its access token expired so the
|
|
61
|
-
* route refreshes, and the fake Google answering the refresh with `status`
|
|
62
|
-
* and `body`.
|
|
63
|
-
*/
|
|
64
|
-
async function refreshAnswered(status: number, body: Record<string, unknown>) {
|
|
65
|
-
const idp: FakeIdp = await createFakeIdp({ ...GOOGLE, clientId: GOOGLE_CLIENT_ID });
|
|
66
|
-
idp.tokenStatus = status;
|
|
67
|
-
idp.refusal = body;
|
|
68
|
-
session = await signInLinkedToGoogle({
|
|
69
|
-
google: { fetch: idp.fetch },
|
|
70
|
-
tokens: {
|
|
71
|
-
accessToken: "expired-upstream-access-token",
|
|
72
|
-
expiresAt: new Date(Date.now() - 60_000),
|
|
73
|
-
refreshToken: STORED_REFRESH_TOKEN,
|
|
74
|
-
idToken: undefined,
|
|
75
|
-
tokenType: "Bearer",
|
|
76
|
-
scope: "openid profile email",
|
|
77
|
-
grantedScope: "openid profile email",
|
|
78
|
-
obtainedAt: undefined,
|
|
79
|
-
},
|
|
80
|
-
});
|
|
81
|
-
const res = await request(session.app)
|
|
82
|
-
.post("/oauth/federation/google/token")
|
|
83
|
-
.set("Authorization", `Bearer ${accessTokenWithFamily(session.sid)}`)
|
|
84
|
-
.send();
|
|
85
|
-
// The library did ask Google: what follows is its reading of the answer.
|
|
86
|
-
expect(
|
|
87
|
-
idp.requests.filter(
|
|
88
|
-
(r) =>
|
|
89
|
-
`${r.url.origin}${r.url.pathname}` === GOOGLE.tokenEndpoint &&
|
|
90
|
-
r.body?.get("grant_type") === "refresh_token",
|
|
91
|
-
),
|
|
92
|
-
).toHaveLength(1);
|
|
93
|
-
return { res, ...session };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
it("keeps them and answers 503 when Google answers 503 with a body naming invalid_grant", async () => {
|
|
97
|
-
const { res, sid, federationTokenStore, sessionLifecycle } = await refreshAnswered(503, {
|
|
98
|
-
error: "invalid_grant",
|
|
99
|
-
error_description: "Backend unavailable",
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
expect(res.status).toBe(503);
|
|
103
|
-
expect(res.body).toEqual({
|
|
104
|
-
error: "temporarily_unavailable",
|
|
105
|
-
error_description: "upstream federation provider temporarily unavailable",
|
|
106
|
-
});
|
|
107
|
-
expect((await federationTokenStore.get(sid, "google"))?.refreshToken).toBe(
|
|
108
|
-
STORED_REFRESH_TOKEN,
|
|
109
|
-
);
|
|
110
|
-
expect(await sessionLifecycle.federations(sid)).toEqual({
|
|
111
|
-
outcome: "listed",
|
|
112
|
-
federations: ["google"],
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("keeps them and answers 503 when Google answers 502 with no OAuth body at all", async () => {
|
|
117
|
-
const { res, sid, federationTokenStore } = await refreshAnswered(502, {});
|
|
118
|
-
|
|
119
|
-
expect(res.status).toBe(503);
|
|
120
|
-
expect((await federationTokenStore.get(sid, "google"))?.refreshToken).toBe(
|
|
121
|
-
STORED_REFRESH_TOKEN,
|
|
122
|
-
);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("ends them on Google's structured 400 invalid_grant", async () => {
|
|
126
|
-
const { res, sid, federationTokenStore, sessionLifecycle } = await refreshAnswered(400, {
|
|
127
|
-
error: "invalid_grant",
|
|
128
|
-
error_description: "Token has been expired or revoked.",
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
expect(res.status).toBe(410);
|
|
132
|
-
expect(res.body.error).toBe("re_authentication_required");
|
|
133
|
-
expect(await federationTokenStore.get(sid, "google")).toBeNull();
|
|
134
|
-
// The route never writes the session's index: the link, now holding no
|
|
135
|
-
// credential, stays listed until federation logout or the session's end.
|
|
136
|
-
expect(await sessionLifecycle.federations(sid)).toEqual({
|
|
137
|
-
outcome: "listed",
|
|
138
|
-
federations: ["google"],
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
});
|