@o3co/create-auth-provider 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.ja.md +93 -47
  2. package/README.md +92 -46
  3. package/package.json +1 -1
  4. package/templates/standalone/.env.example +34 -0
  5. package/templates/standalone/Dockerfile +1 -1
  6. package/templates/standalone/README.ja.md +565 -130
  7. package/templates/standalone/README.md +356 -92
  8. package/templates/standalone/config/application.conf +77 -2
  9. package/templates/standalone/docker-compose.production.yml +7 -0
  10. package/templates/standalone/docker-compose.yml +7 -0
  11. package/templates/standalone/gitignore +3 -2
  12. package/templates/standalone/package.json +3 -2
  13. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +49 -15
  14. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1103 -0
  15. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +241 -0
  16. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1100 -0
  17. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +40 -5
  18. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +445 -0
  19. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +446 -0
  20. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +133 -0
  21. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +85 -0
  22. package/templates/standalone/src/__tests__/google-session.fixture.mts +304 -0
  23. package/templates/standalone/src/__tests__/host-routes.test.mts +141 -0
  24. package/templates/standalone/src/__tests__/listen.test.mts +128 -0
  25. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  26. package/templates/standalone/src/__tests__/metrics.test.mts +20 -5
  27. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +187 -0
  28. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +139 -0
  29. package/templates/standalone/src/__tests__/replica-safety.test.mts +13 -3
  30. package/templates/standalone/src/__tests__/router-error-answers.test.mts +149 -0
  31. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +14 -0
  32. package/templates/standalone/src/__tests__/shutdown.test.mts +253 -22
  33. package/templates/standalone/src/__tests__/smoke.test.mts +39 -20
  34. package/templates/standalone/src/__tests__/store-credential.test.mts +276 -0
  35. package/templates/standalone/src/app.mts +30 -51
  36. package/templates/standalone/src/buildModules.mts +94 -12
  37. package/templates/standalone/src/listen.mts +66 -0
  38. package/templates/standalone/src/logger.mts +7 -4
  39. package/templates/standalone/src/modules.mts +118 -12
  40. package/templates/standalone/src/routes.mts +95 -0
  41. package/templates/standalone/src/shutdown.mts +90 -18
  42. package/templates/standalone/vitest.supertest-loopback.mts +22 -3
  43. package/templates/versions.json +15 -14
  44. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  45. package/templates/standalone/src/terminalError.mts +0 -60
@@ -159,6 +159,44 @@ replaySeenSet {
159
159
  adapter = ${?REPLAY_SEEN_SET_ADAPTER}
160
160
  }
161
161
 
162
+ # #593: federation grants — a user's standing consent that a backend or an
163
+ # agent may obtain upstream IdP tokens with no session behind the call. Off by
164
+ # default; FEDERATION_GRANTS_ENABLED=true installs the routes, the two stores
165
+ # below and the subject-revocation service. Enabling it is a statement that
166
+ # the deployment has a consent page (FEDERATION_GRANTS_CONSENT_URL, no default),
167
+ # a `callbackURL` on every connection, and a user repository that covers each
168
+ # connection's registration (or FEDERATION_GRANTS_IDENTITY_LOOKUP=unsupported,
169
+ # the recorded decision not to make that check) — each refused at boot when
170
+ # missing. Connections and the encryption key ring have no environment form;
171
+ # write them in a deployment-owned HOCON layer (README: Federation Grants).
172
+ federationGrants {
173
+ enabled = false
174
+ enabled = ${?FEDERATION_GRANTS_ENABLED}
175
+ identityLookup = "required"
176
+ identityLookup = ${?FEDERATION_GRANTS_IDENTITY_LOOKUP}
177
+ consent {
178
+ url = ${?FEDERATION_GRANTS_CONSENT_URL}
179
+ }
180
+ encryptionMode = "required"
181
+ encryptionMode = ${?FEDERATION_GRANTS_ENCRYPTION_MODE}
182
+ }
183
+
184
+ # "redis" for both, as for the denylist above: a grant is an offline credential
185
+ # that must outlive a restart, and every store this file already selects shares
186
+ # the same socket. Redis grants need the subject boundary in Redis too
187
+ # (USER_SESSION_STORES_ADAPTER=redis, which both compose files set) — boot
188
+ # refuses the pairing with a memory boundary, because the grants would outlive
189
+ # what ends them. Single-instance local work can set both to "memory".
190
+ federationGrantStore {
191
+ adapter = "redis"
192
+ adapter = ${?FEDERATION_GRANT_STORE_ADAPTER}
193
+ }
194
+
195
+ federationGrantIntentStore {
196
+ adapter = "redis"
197
+ adapter = ${?FEDERATION_GRANT_INTENT_STORE_ADAPTER}
198
+ }
199
+
162
200
  # #527: where consent to a client that is not first-party is recorded.
163
201
  # "none" (the default) wires no store and such clients are refused at
164
202
  # /authorize; "memory" is single-replica only (refused under
@@ -269,6 +307,20 @@ federations {
269
307
 
270
308
  # Where the browser goes after a callback carrying no `redirect_to`.
271
309
  # clientUrl = ${?FEDERATIONS_GOOGLE_CLIENT_URL}
310
+
311
+ # RFC 9207: Google always returns `iss` on the callback, so a callback
312
+ # without one is refused. `false` is the way out if Google ever stops
313
+ # sending it. It permits a missing `iss` only — one that is sent and is not
314
+ # Google's is refused either way.
315
+ # requireAuthorizationResponseIss = ${?FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS}
316
+
317
+ # Whether sign-in asks Google for a refresh token. Unset is "offline":
318
+ # every sign-in shows Google's consent screen, because Google issues a
319
+ # refresh token only there, and every session gets one that
320
+ # POST /oauth/federation/google/token can refresh with. "online" shows the
321
+ # consent screen on the first sign-in only and gets no refresh token at
322
+ # all — for a deployment that uses Google to sign in and nothing more.
323
+ accessType = ${?FEDERATIONS_GOOGLE_ACCESS_TYPE}
272
324
  }
273
325
 
274
326
  # Any OpenID Connect provider (Okta, Entra ID, Auth0, Keycloak, your own
@@ -327,17 +379,40 @@ repositories {
327
379
  # Both URLs carry plaintext user credentials, so both must be absolute
328
380
  # https URLs. http:// is accepted only for a loopback host (localhost,
329
381
  # 127.0.0.0/8, [::1]); a private-range address or a container-network
330
- # service name still needs https. Boot fails if either is not.
382
+ # service name still needs https. Boot fails if either is not. Each must
383
+ # also be the endpoint that answers: no request follows a 3xx, so a URL
384
+ # that redirects fails every call.
331
385
  authenticateUrl = ${?CLIENT_USER_AUTHENTICATE_URL}
332
386
  authenticateByTokenUrl = ${?CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL}
333
387
  # #482: optional. When set, a signed-in user can link a second federated
334
388
  # identity (GET /session/oauth/federation/<name>?link=1): the Store
335
389
  # receives { userId, provider, sub, token, claims } and decides — never on
336
- # an unverified or relay e-mail. Same https rule as the two URLs above.
390
+ # an unverified or relay e-mail. Same https and no-redirect rule as the two URLs above.
337
391
  # Also declared in core's reference.conf, so a composition that does not
338
392
  # layer this file gets the same override.
339
393
  # Without it, link=1 is refused with link_unsupported.
340
394
  linkFederatedIdentityUrl = ${?CLIENT_USER_LINK_FEDERATED_IDENTITY_URL}
395
+ # #613: optional. The Store's identity lookup for federation grants —
396
+ # what lets this deployment keep FEDERATION_GRANTS_IDENTITY_LOOKUP=required
397
+ # with a connection configured. Same https and no-redirect rule. Declare what the Store
398
+ # covers, per registration, in a HOCON layer (README: Federation Grants):
399
+ # federatedIdentityLookupCoverage = [
400
+ # { provider = "entra-files", issuer = "...", clientId = "...", requiredClaims = ["tid", "oid"] }
401
+ # ]
402
+ findSubjectByFederatedIdentityUrl = ${?CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL}
403
+ # Optional. Sent on every Store request as `Authorization: Bearer <token>`
404
+ # so the Store can refuse any caller but this deployment; unset, none is
405
+ # sent. A bare token (no "Bearer " prefix), at least 32 bytes of key
406
+ # material -- `openssl rand -hex 32`. Boot fails on a weak, malformed or
407
+ # blank value. One token goes to every URL above: one trust domain. A
408
+ # Store that refuses it with 401 (or 403) + `WWW-Authenticate: Bearer`
409
+ # makes every Store call an outage rather than a wrong password: 503 and
410
+ # a logged error at login, federation callbacks and the jwt-bearer
411
+ # grant; a temporarily_unavailable redirect and
412
+ # federation_grant_callback_unavailable (err.name
413
+ # StoreCredentialRefusedError) at the federation-grants callback. Also
414
+ # declared in core's reference.conf.
415
+ bearerToken = ${?CLIENT_USER_BEARER_TOKEN}
341
416
  # Positive integer, milliseconds, <= 2147483647. Boot fails otherwise —
342
417
  # including on a blank env override, which HOCON substitutes as "".
343
418
  timeout = 5000
@@ -49,6 +49,13 @@ services:
49
49
  # security group, a firewall rule, or the proxy's own network. Do not
50
50
  # widen this line and call it done.
51
51
  - "127.0.0.1:3000:3000"
52
+ # #593 slice 7: compose's default grace is ten seconds — the HTTP drain
53
+ # budget alone. With federation grants on, `src/app.mts` gives cleanup 45
54
+ # more for the drain of a rotated upstream credential's write, and SIGKILL
55
+ # has to arrive after that: drain (10) + cleanup (45) + an exit margin.
56
+ # The cleanup allowance grows by itself with `federationGrants.upstreamHardTimeoutMs`,
57
+ # `persistRetryBudgetMs` and `lockWaitMs`; raise this to match when you raise them.
58
+ stop_grace_period: 60s
52
59
  depends_on:
53
60
  redis:
54
61
  condition: service_healthy
@@ -8,6 +8,13 @@ services:
8
8
  volumes:
9
9
  - ./src:/home/node/src
10
10
  - ./config:/home/node/config
11
+ # #593 slice 7: compose's default grace is ten seconds — the HTTP drain
12
+ # budget alone. With federation grants on, `src/app.mts` gives cleanup 45
13
+ # more for the drain of a rotated upstream credential's write, and SIGKILL
14
+ # has to arrive after that: drain (10) + cleanup (45) + an exit margin.
15
+ # The cleanup allowance grows by itself with `federationGrants.upstreamHardTimeoutMs`,
16
+ # `persistRetryBudgetMs` and `lockWaitMs`; raise this to match when you raise them.
17
+ stop_grace_period: 60s
11
18
  depends_on:
12
19
  redis:
13
20
  condition: service_healthy
@@ -1,6 +1,7 @@
1
1
  # #407: without this file, the first `git add .` in a scaffolded project
2
- # commits the two things the README's own setup steps tell you to create right
3
- # here — the `.env` holding SESSION_SECRET, and the signing key pair.
2
+ # commits the two things a local setup creates right here — the signing key
3
+ # pair the README's Usage generates, and the `.env` (holding SESSION_SECRET)
4
+ # the compose files read.
4
5
 
5
6
  # Secrets and per-machine configuration
6
7
  .env
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth-provider-standalone",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -12,7 +12,7 @@
12
12
  "build": "tsc",
13
13
  "start": "node dist/app.mjs",
14
14
  "debug": "NODE_OPTIONS='--conditions=development' tsx watch src/app.mts",
15
- "test": "vitest run --passWithNoTests"
15
+ "test": "tsc --noEmit && vitest run --passWithNoTests"
16
16
  },
17
17
  "imports": {
18
18
  "#/*": {
@@ -23,6 +23,7 @@
23
23
  "dependencies": {
24
24
  "@o3co/auth-provider-core": "workspace:*",
25
25
  "@o3co/auth-provider-federation-google": "workspace:*",
26
+ "@o3co/auth-provider-federation-grants": "workspace:*",
26
27
  "@o3co/auth-provider-federation-oidc": "workspace:*",
27
28
  "@o3co/auth-provider-foundation": "workspace:*",
28
29
  "@o3co/auth-provider-oauth": "workspace:*",
@@ -27,7 +27,7 @@
27
27
  */
28
28
 
29
29
  import { fileURLToPath } from "node:url";
30
- import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
30
+ import { type AppConfig, AppConfigSchema, type Logger } from "@o3co/auth-provider-core";
31
31
  import { parseFile } from "@o3co/ts.hocon";
32
32
  import { validate } from "@o3co/ts.hocon/zod";
33
33
  import { afterEach, describe, expect, it, vi } from "vitest";
@@ -54,15 +54,47 @@ function loadShipped(env: Record<string, string> = {}): AppConfig {
54
54
  );
55
55
  }
56
56
 
57
- /** Every console warning `buildModules` emits about the deprecated key. */
58
- function aliasWarnings(config: AppConfig): string[] {
59
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
60
- buildModules(config);
61
- return warn.mock.calls
62
- .map((call) => String(call[0]))
63
- .filter((message) => message.includes("oauth.accessToken.expiresIn"));
57
+ /** What `buildModules` hands the logger it is given, per level. */
58
+ function logged(config: AppConfig): Array<{ level: string; args: unknown[] }> {
59
+ const calls: Array<{ level: string; args: unknown[] }> = [];
60
+ const record =
61
+ (level: string) =>
62
+ (...args: unknown[]): void => {
63
+ calls.push({ level, args });
64
+ };
65
+ const logger: Logger = {
66
+ trace: record("trace"),
67
+ debug: record("debug"),
68
+ info: record("info"),
69
+ warn: record("warn"),
70
+ error: record("error"),
71
+ fatal: record("fatal"),
72
+ child: () => logger,
73
+ };
74
+ buildModules(config, { logger });
75
+ return calls;
64
76
  }
65
77
 
78
+ /** The one line the alias is reported by: object-first, an event name, at warn. */
79
+ const ALIAS_WARNING = {
80
+ level: "warn",
81
+ args: [
82
+ {
83
+ key: "oauth.accessToken.expiresIn",
84
+ env: "OAUTH_ACCESS_TOKEN_EXPIRES_IN",
85
+ replacement: "oauth.accessToken.defaultExpiresIn",
86
+ replacementEnv: "OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
87
+ },
88
+ "config_key_deprecated",
89
+ ],
90
+ };
91
+
92
+ /** Every line `buildModules` writes about the deprecated key. */
93
+ const aliasWarnings = (config: AppConfig) =>
94
+ logged(config).filter(
95
+ ({ args }) => (args[0] as { key?: unknown } | undefined)?.key === "oauth.accessToken.expiresIn",
96
+ );
97
+
66
98
  describe("the deprecated access-token lifetime alias", () => {
67
99
  afterEach(() => {
68
100
  vi.restoreAllMocks();
@@ -81,12 +113,8 @@ describe("the deprecated access-token lifetime alias", () => {
81
113
  );
82
114
  });
83
115
 
84
- it("warns once when OAUTH_ACCESS_TOKEN_EXPIRES_IN still decides the default", () => {
85
- const warnings = aliasWarnings(loadShipped({ OAUTH_ACCESS_TOKEN_EXPIRES_IN: "900" }));
86
- expect(warnings).toHaveLength(1);
87
- expect(warnings[0]).toMatch(/deprecated/);
88
- expect(warnings[0]).toMatch(/oauth\.accessToken\.defaultExpiresIn/);
89
- expect(warnings[0]).toMatch(/OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN/);
116
+ it("warns once, object-first, when OAUTH_ACCESS_TOKEN_EXPIRES_IN still decides the default", () => {
117
+ expect(logged(loadShipped({ OAUTH_ACCESS_TOKEN_EXPIRES_IN: "900" }))).toEqual([ALIAS_WARNING]);
90
118
  });
91
119
 
92
120
  it("warns for a hand-built configuration that overrides only the old key", () => {
@@ -96,6 +124,12 @@ describe("the deprecated access-token lifetime alias", () => {
96
124
  ...config,
97
125
  oauth: { ...config.oauth, accessToken: { expiresIn: 900 } },
98
126
  }),
99
- ).toHaveLength(1);
127
+ ).toEqual([ALIAS_WARNING]);
128
+ });
129
+
130
+ it("writes the same line through consoleLogger when no logger is handed over", () => {
131
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
132
+ buildModules(loadShipped({ OAUTH_ACCESS_TOKEN_EXPIRES_IN: "900" }));
133
+ expect(warn.mock.calls).toEqual([ALIAS_WARNING.args]);
100
134
  });
101
135
  });