@o3co/create-auth-provider 0.16.0 → 0.17.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.
Files changed (90) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +59 -26
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +293 -162
  14. package/templates/standalone/README.md +533 -199
  15. package/templates/standalone/config/application.conf +196 -341
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +19 -20
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +216 -141
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +48 -33
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +77 -58
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +656 -269
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +425 -84
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +91 -70
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +7 -5
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +22 -19
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +53 -42
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +7 -11
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +153 -142
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -252
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +91 -50
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +64 -79
  73. package/templates/standalone/src/buildModules.mts +212 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +12 -18
  76. package/templates/standalone/src/logger.mts +31 -60
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +458 -476
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +8 -14
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +45 -123
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
@@ -1,121 +1,102 @@
1
1
  # Application-level overrides for the standalone template.
2
2
  #
3
- # The library ships its declarative defaults in
4
- # @o3co/auth-provider-core/reference.conf
5
- # (resolved at boot via standalone/src/app.mts withFallback chain).
6
- # Values omitted here inherit from that baseline; add overrides only
7
- # for keys this deployment intentionally diverges on.
3
+ # Defaults come from config/reference.conf (the template's own modules) and
4
+ # from the reference.conf of each package its modules come from, core's last.
5
+ # Values omitted here inherit from them; add overrides only for keys this
6
+ # deployment intentionally diverges on. Write a value for a key the last
7
+ # block of this file binds above that block: one written below it wins over
8
+ # the variable.
8
9
 
9
- http {
10
- port = 3000
11
- port = ${?HTTP_PORT}
12
- # false | true | <hop count> | <address list>. Prefer naming the proxy:
13
- # HTTP_TRUST_PROXY=10.0.0.0/8 (or `loopback` for a sidecar) rather than
14
- # `true`, which trusts a forwarded client address from anyone who can reach
15
- # this process. See the header comment in the library's reference.conf.
16
- trustProxy = false
17
- trustProxy = ${?HTTP_TRUST_PROXY}
18
- }
19
-
20
- # #287: where the audit trail goes. Before this block the template wired no
21
- # sink at all, so every security event the routes emit -- token.issued.failure,
22
- # authorize.rejected / authorize.granted, logout.cascade_failed, and the shared
23
- # rate-limit guard's rate_limit.unavailable -- was discarded by the artifact
24
- # operators deploy. Nothing failed and nothing warned: `emitAuditEvent` is a
25
- # no-op when the slot is empty.
10
+ # Which adapter fills each slot -- the stores, the repositories, the audit
11
+ # sink -- is the template's own `adapters` section, whose defaults
12
+ # config/reference.conf holds with each one's variable (ADAPTERS_*). Override
13
+ # one in a layer of your own or with its variable:
26
14
  #
27
- # "logger" rather than reference.conf's "console": both write newline-delimited
28
- # JSON to stdout, but "logger" writes it through the same pino stream that
29
- # carries every other line this template emits, so a log aggregator ingests
30
- # application logs and audit events with one parser and separates them on
31
- # `name` ("provider" vs "audit"). "console" writes the bare event with no log
32
- # envelope -- no level, no time -- which most pipelines then timestamp by
33
- # ingestion. Set AUDIT_SINK_TYPE=console if that is what your pipeline wants.
15
+ # adapters.rateLimiter = "redis"
34
16
  #
35
17
  # The audit stream's level is FIXED at info and `logging.level` does not gate
36
- # it. LOG_LEVEL=warn is an ordinary production setting and LOG_LEVEL=silent a
37
- # legitimate one; either silencing the audit trail would be #287 again, reached
38
- # from the operator's side. Where events go is chosen here, and this selector
39
- # has no "none" -- an unknown type fails boot naming the sinks that exist,
40
- # rather than producing a deployment with no audit trail (#304).
18
+ # it: LOGGING_LEVEL=warn is an ordinary production setting and
19
+ # LOGGING_LEVEL=silent a legitimate one, and either silencing the audit trail
20
+ # would lose the record of every security event.
21
+
22
+ # The grant switches, each in the section of the module that installs the
23
+ # grant. The template is a typical web-app OP, so it turns on the universal
24
+ # user-auth path: the session grant (oauth-session) and the authorization-code
25
+ # and refresh-token grants (oauth-authorization). client_credentials and the
26
+ # jwt-bearer grant stay off; deployments that need M2M flows set
27
+ # OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED=true or write
28
+ # `oauth-authorization.grants.clientCredentials.enabled = true` in their own
29
+ # layer.
30
+ #
31
+ # The template reads these switches before it chooses its modules, from its
32
+ # own files, so this file writes every switch and binds its variable: one set
33
+ # only in the package's reference.conf would read as unset there, and a
34
+ # variable bound only there would install no grant. The env-override line is
35
+ # repeated after each value because a layer's own value wins over a variable
36
+ # bound below it.
41
37
  #
42
- # To point this at a real sink (SIEM, log pipeline, message bus): register the
43
- # builder in `auditSinkModule` (src/modules.mts), name it here, and put its
44
- # options in this block -- `sink { type = "splunk-hec", splunk-hec { ... } }`.
45
- audit {
46
- sink {
47
- type = "logger"
48
- type = ${?AUDIT_SINK_TYPE}
38
+ # PKCE is mandatory for every authorization-code client and `S256` is the only
39
+ # method the provider accepts -- fixed policy (OAuth 2.1 §4.1.1 / RFC 9700
40
+ # §2.1.1), not a deployment setting. A legacy client that genuinely cannot
41
+ # compute SHA-256 is opted into `plain` one client at a time, with
42
+ # `allowPlainPkce: true` in clients.yaml.
43
+ oauth-session {
44
+ enabled = true
45
+ enabled = ${?OAUTH_SESSION_ENABLED}
46
+ }
47
+
48
+ oauth-authorization.grants {
49
+ authorizationCode {
50
+ enabled = true
51
+ enabled = ${?OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED}
52
+ }
53
+ refreshToken {
54
+ enabled = true
55
+ enabled = ${?OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED}
56
+ }
57
+ clientCredentials {
58
+ enabled = false
59
+ enabled = ${?OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED}
60
+ }
61
+ jwtBearer {
62
+ enabled = false
63
+ enabled = ${?OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED}
49
64
  }
50
65
  }
51
66
 
52
67
  oauth {
53
- grants {
54
- # Template is a typical web-app OP — enable the universal user-auth path.
55
- # client_credentials remains off (reference.conf default); deployments that
56
- # need M2M flows set OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED=true or add
57
- # `client_credentials { enabled = true }` in their own application.conf.
58
- #
59
- # The env-override line (`enabled = ${?OAUTH_GRANTS_*_ENABLED}`) is repeated
60
- # at this layer because HOCON precedence: application.conf wins over
61
- # reference.conf, so without the env-override line at the template layer,
62
- # an operator setting OAUTH_GRANTS_SESSION_ENABLED=false (etc.) cannot
63
- # disable the grant — the env var only takes effect at the layer where
64
- # the substitution is written.
65
- session {
66
- enabled = true
67
- enabled = ${?OAUTH_GRANTS_SESSION_ENABLED}
68
- }
69
- # #273: there is no `pkce` block here any more. PKCE is mandatory for
70
- # every authorization-code client and `S256` is the only method the
71
- # provider accepts — that is fixed policy (OAuth 2.1 §4.1.1 / RFC 9700
72
- # §2.1.1), not a deployment setting, so `requireS256` / `required` /
73
- # `defaultMethod` / `supportedMethods` are gone. A legacy client that
74
- # genuinely cannot compute SHA-256 is opted into `plain` one client at a
75
- # time, with `allowPlainPkce: true` in clients.yaml. The
76
- # OAUTH_GRANTS_AUTHORIZATION_CODE_PKCE_REQUIRE_S256 tombstone in core's
77
- # reference.conf makes a still-exported env var warn at boot.
78
- authorization_code {
79
- enabled = true
80
- enabled = ${?OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED}
81
- }
82
- refresh_token {
83
- enabled = true
84
- enabled = ${?OAUTH_GRANTS_REFRESH_TOKEN_ENABLED}
85
- }
86
- # client_credentials: omitted -> inherits reference.conf default (enabled = false).
87
- # Operators that need M2M flows set OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED=true
88
- # or add `client_credentials { enabled = true }` here.
89
- }
90
- # OR-9: adapter switch for the OAuth authorization-code repository. When
91
- # set to "redis", the standalone composition root wires
92
- # `redisCodeRepositoryModule` against the shared ioredis socket
93
- # (`standaloneRedisClientsModule`). Memory-only deployments lose codes on
94
- # restart and across replicas — multi-replica production MUST use "redis".
95
- # Supersedes the legacy `repositories.code.type` switch (kept below as
96
- # deprecated for backward compat — see CHANGELOG for the removal version).
97
- # #481: the Authentication Context Class References this deployment can
68
+ # The Authentication Context Class References this deployment can
98
69
  # vouch for, each mapped to the RFC 8176 `amr` values a session must carry
99
- # (`pwd` for a password login, `hwk` for a passkey, `fed` for a federated
100
- # login, `mfa` when a resumed MFA login records it). `/authorize` answers
70
+ # (`pwd` for a password login, `fed` for a federated login) — or to a list
71
+ # of such lists, any one of which satisfies it. `/authorize` answers
101
72
  # `acr_values` from this table alone — anything else is
102
73
  # `unmet_authentication_requirements` — and discovery advertises the keys.
103
74
  #
104
75
  # authorize.acrValues {
105
76
  # "urn:example:pwd" = ["pwd"]
106
- # "urn:example:mfa" = ["pwd", "mfa"]
107
77
  # }
108
78
  #
109
- # #267: /authorize refuses any client not marked `firstParty: true` in
79
+ # An entry nothing this deployment installs can satisfy is dropped at boot:
80
+ # not advertised, answered `unmet_authentication_requirements`, and logged
81
+ # once as `acr_value_unsatisfiable`. One that needs `fed` needs a
82
+ # federation installed; one that needs `mfa`, `hwk` or `swk` needs MFA
83
+ # installed (MFA_MODE, below), or a federation that trusts its upstream
84
+ # IdP's `amr`. One that lacks only a second factor while MFA is off is
85
+ # logged at info, not warn.
86
+ #
87
+ # While MFA_MODE installs MFA, the template adds `"urn:o3co:acr:mfa" =
88
+ # ["mfa"]`, met by a session with a verified second factor, unless this
89
+ # table writes that entry itself; with MFA off it adds nothing.
90
+ # Phishing-resistant authentication would be:
91
+ #
92
+ # "urn:o3co:acr:phr" = [["hwk"], ["swk"]]
93
+
94
+ # /authorize refuses any client not marked `firstParty: true` in
110
95
  # clients.yaml — the template's example clients are marked. There is no
111
- # opt-out: the one-time `allowUnmarkedClients` migration flag was removed
112
- # (#330), and a config or environment still setting it fails at boot with
96
+ # opt-out: the one-time `allowUnmarkedClients` migration flag is removed,
97
+ # and a config or environment still setting it fails at boot with
113
98
  # migration instructions (via the tombstone in core's reference.conf).
114
- code {
115
- adapter = "redis"
116
- adapter = ${?OAUTH_CODE_ADAPTER}
117
- }
118
- # #277: POST /oauth/revoke revokes access tokens by writing their `jti` to the
99
+ # POST /oauth/revoke revokes access tokens by writing their `jti` to the
119
100
  # denylist wired below, and boot FAILS if that denylist is missing — an
120
101
  # RFC 7009 endpoint must answer 200, so an unwired denylist would tell every
121
102
  # operator their token is revoked while it kept working until expiry.
@@ -131,45 +112,52 @@ oauth {
131
112
  }
132
113
  }
133
114
 
134
- # #277: backend for the access-token denylist.
135
- #
136
- # "redis" by default, not "memory": the memory adapter keeps revocations in one
137
- # process, so a token revoked on one replica stays valid on every other — and
138
- # `deployment.mode = "multi"` refuses it by name for exactly that reason. It
139
- # shares the ioredis socket configured under `refreshTokenFamilyStore.redis`
140
- # below, so switching it on costs no extra connection.
141
- #
142
- # Single-instance local development can set ACCESS_TOKEN_DENYLIST_ADAPTER=memory
143
- # to run without Redis.
144
- accessTokenDenylist {
145
- adapter = "redis"
146
- adapter = ${?ACCESS_TOKEN_DENYLIST_ADAPTER}
147
- }
115
+ # The session requirements this deployment expects, in core's own section:
116
+ # the extensions that change what "logged in" means, each registered by a
117
+ # module (the MFA package's is named `mfa`). Boot compares the list with what
118
+ # the installed modules register: a name here that nothing registers refuses
119
+ # the boot (`session-requirement-missing`), and so does a registered
120
+ # requirement the list leaves out (`session-requirements-undeclared`). When
121
+ # MFA_MODE (`mfaMode`, config/reference.conf) installs MFA, the template
122
+ # adds `mfa` to this list and writes `secondFactorAuthority = "mfa"` beside
123
+ # it (one written here other than `mfa` refuses the boot); a deployment that
124
+ # adds a module registering a
125
+ # requirement of its own adds its name here. No environment variable sets the
126
+ # list.
127
+ core.sessionRequirements.expected = []
148
128
 
149
- # #484: backend for the replay seen-set — the `jti` single-use record behind
150
- # `private_key_jwt` client authentication (a client registered with `jwks` or
151
- # `jwksUri` in config/clients.yaml). Same reasoning as the denylist above:
152
- # "memory" forks per replica, so a captured client assertion would replay
153
- # once per replica, and `deployment.mode = "multi"` refuses it by name. It
154
- # shares the ioredis socket, so it costs no extra connection.
155
- #
156
- # Single-instance local development can set REPLAY_SEEN_SET_ADAPTER=memory.
157
- replaySeenSet {
158
- adapter = "redis"
159
- adapter = ${?REPLAY_SEEN_SET_ADAPTER}
160
- }
129
+ # Multi-factor authentication, installed by MFA_MODE (`mfaMode`,
130
+ # config/reference.conf): "off" (unset) installs nothing of it; "optional" or
131
+ # "required" installs the MFA package's modules (the TOTP, recovery-code and
132
+ # email factors, the operator reset) and writes `mfa.mode` from the switch.
133
+ # The MFA package's reference.conf holds the `mfa` section with its
134
+ # variables: the key ring's first key, MFA_ENCRYPTION_KEY (`openssl rand
135
+ # -base64 32`; none, and the boot is refused), and the page a step-up starts
136
+ # on, MFA_PAGE_URL (`/mfa`). One Store call's time, MFA_STORE_TIMEOUT_MS
137
+ # (5000 ms), must be at least every store's own per-call timeout, the user
138
+ # directory's REPOSITORIES_USER_HTTP_TIMEOUT among them: raise the two
139
+ # together; above 37500 ms the boot is refused. Mail --
140
+ # the account-email proof, the email factor's codes -- goes through the mail
141
+ # sender, which needs STANDARD_SMTP_MAIL_SENDER_HOST and
142
+ # STANDARD_SMTP_MAIL_SENDER_FROM outside development. While MFA is off,
143
+ # nothing written under `mfa` is read.
161
144
 
162
- # #593: federation grants — a user's standing consent that a backend or an
145
+ # Federation grants — a user's standing consent that a backend or an
163
146
  # agent may obtain upstream IdP tokens with no session behind the call. Off by
164
147
  # default; FEDERATION_GRANTS_ENABLED=true installs the routes, the two stores
165
- # below and the subject-revocation service. Enabling it is a statement that
148
+ # `adapters.federationGrantStore` and `.federationGrantIntentStore` select
149
+ # (none by default, which boot refuses naming the store) and the
150
+ # subject-revocation service. Enabling it is a statement that
166
151
  # the deployment has a consent page (FEDERATION_GRANTS_CONSENT_URL, no default),
167
152
  # a `callbackURL` on every connection, and a user repository that covers each
168
153
  # connection's registration (or FEDERATION_GRANTS_IDENTITY_LOOKUP=unsupported,
169
154
  # 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 {
155
+ # missing. Connections and the Redis grant store's key ring have no
156
+ # environment form; write them in a deployment-owned HOCON layer (README:
157
+ # Federation Grants). The template reads `federation-grants.enabled` before it
158
+ # chooses its modules, from its own files, which is why this file binds
159
+ # FEDERATION_GRANTS_ENABLED too.
160
+ federation-grants {
173
161
  enabled = false
174
162
  enabled = ${?FEDERATION_GRANTS_ENABLED}
175
163
  identityLookup = "required"
@@ -177,103 +165,87 @@ federationGrants {
177
165
  consent {
178
166
  url = ${?FEDERATION_GRANTS_CONSENT_URL}
179
167
  }
180
- encryptionMode = "required"
181
- encryptionMode = ${?FEDERATION_GRANTS_ENCRYPTION_MODE}
182
168
  }
183
169
 
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
-
200
- # #527: where consent to a client that is not first-party is recorded.
201
- # "none" (the default) wires no store and such clients are refused at
202
- # /authorize; "memory" is single-replica only (refused under
203
- # DEPLOYMENT_MODE=multi by name); "redis" (#561) keeps the consent records and
204
- # the requests parked while the consent page asks on the shared ioredis socket,
205
- # and is what a deployment of more than one replica selects. Its key namespace
206
- # is REDIS_CONSENT_STORE_KEY_PREFIX (default "consent:").
207
- consentStore {
208
- adapter = "none"
209
- adapter = ${?CONSENT_STORE_ADAPTER}
170
+ # The Redis grant store seals every credential; "required" needs its key ring,
171
+ # `redis-federation-grant-store.encryptionKeys`.
172
+ redis-federation-grant-store {
173
+ encryptionMode = "required"
174
+ encryptionMode = ${?REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE}
210
175
  }
211
176
 
212
- session {
213
- secret = ${?SESSION_SECRET}
214
- # MIN-2: __Host- prefix requires Secure, Path=/, and no Domain attribute.
215
- # If SESSION_SECURE=false or SESSION_DOMAIN is set, also override
216
- # SESSION_NAME to a non-__Host- value.
177
+ # The session store's section: the session cookie and the store
178
+ # express-session keeps sessions in. The template reads `storage` before it
179
+ # chooses its modules (a memory store declares itself replica-unsafe), from
180
+ # its own files, which is why this file binds SESSION_STORE_STORAGE_TYPE too.
181
+ session-store {
182
+ secret = ${?SESSION_STORE_SECRET}
183
+ # A `__Host-` name requires Secure, Path=/ and no Domain attribute. If
184
+ # SESSION_STORE_SECURE=false or SESSION_STORE_DOMAIN is set, also set
185
+ # SESSION_STORE_NAME to a name without the prefix.
217
186
  name = "__Host-auth.session"
218
- name = ${?SESSION_NAME}
187
+ name = ${?SESSION_STORE_NAME}
219
188
  maxAge = 3600000
220
- maxAge = ${?SESSION_MAX_AGE}
189
+ maxAge = ${?SESSION_STORE_MAX_AGE}
221
190
  secure = true
222
- secure = ${?SESSION_SECURE}
191
+ secure = ${?SESSION_STORE_SECURE}
223
192
  sameSite = "lax"
224
- sameSite = ${?SESSION_SAME_SITE}
193
+ sameSite = ${?SESSION_STORE_SAME_SITE}
225
194
  domain = null
226
- domain = ${?SESSION_DOMAIN}
227
- # #405: every URL `POST /session/login` may accept as `redirect_to`, matched
228
- # EXACTLY -- scheme, host, port, path and query all significant. Empty (the
229
- # default) refuses every `redirect_to`, so leave it alone unless your login
230
- # page actually posts one.
231
- #
232
- # redirectAllowlist = ["https://app.example.com/welcome"]
233
- #
234
- # `http://` is accepted for loopback hosts only. With `domain` set above, an
235
- # entry outside it fails boot rather than being silently ignored. The
236
- # federation flow has a separate list per provider (see `federations` below).
237
- redirectAllowlist = []
195
+ domain = ${?SESSION_STORE_DOMAIN}
238
196
  storage {
239
197
  type = "redis"
240
- type = ${?SESSION_STORAGE_TYPE}
198
+ type = ${?SESSION_STORE_STORAGE_TYPE}
241
199
  redis {
242
200
  url = "redis://localhost:6379"
243
- url = ${?SESSION_STORAGE_REDIS_URL}
244
- password = ${?SESSION_STORAGE_REDIS_PASSWORD}
201
+ url = ${?SESSION_STORE_STORAGE_REDIS_URL}
202
+ password = ${?SESSION_STORE_STORAGE_REDIS_PASSWORD}
245
203
  }
246
204
  }
247
205
  }
248
206
 
249
- # Rate limiting for SESSION routes (e.g. /session/login bruteforce protection).
250
- # Uses windowMs (milliseconds) — consumed by express-rate-limit in Session.mts.
251
- # NOTE (IH-18): this section does NOT govern OAuth endpoint rate limiting.
252
- # For OAuth rate limiting (/token, /authorize), use the `rateLimiter`
253
- # component (memoryRateLimiterModule or redisRateLimiterModule) with
254
- # its own `memoryRateLimiter.*` / `redisRateLimiter.*` config section
255
- # (uses `windowSeconds` instead of `windowMs`). Switch via
256
- # `rateLimiter.adapter = "memory" | "redis"` (see reference.conf).
257
- rateLimit {
258
- # `login` is per-deployment tuning (bruteforce-window + limit), kept in
259
- # this layer so operators can tweak without forking reference.conf.
260
- login { windowMs = 900000, limit = 20 }
261
- # rateLimit.failMode: inherits reference.conf default ("closed").
262
- # Deployments that prefer fail-open override here:
263
- # failMode = "open"
264
- # failMode = ${?RATE_LIMIT_FAIL_MODE}
207
+ # The session module's section: the login routes' settings.
208
+ session {
209
+ # Every URL `POST /session/login` may accept as `redirect_to`, matched
210
+ # EXACTLY -- scheme, host, port, path and query all significant. Empty (the
211
+ # default) refuses every `redirect_to`, so leave it alone unless your login
212
+ # page actually posts one.
213
+ #
214
+ # redirectAllowlist = ["https://app.example.com/welcome"]
215
+ #
216
+ # `http://` is accepted for loopback hosts only. With `session-store.domain`
217
+ # set above, an entry outside it fails boot rather than being silently
218
+ # ignored. The federation flow has a separate list per provider (see
219
+ # `core.federations` below).
220
+ redirectAllowlist = []
221
+ # Rate limiting for POST /session/login (brute-force protection), in
222
+ # windowMs (milliseconds); the session module contributes it as the `login`
223
+ # budget, kept in this layer so operators can tweak it. It does NOT govern
224
+ # OAuth endpoint rate limiting: the `rateLimiter` component
225
+ # (memoryRateLimiterModule or redisRateLimiterModule) reads its own section,
226
+ # `core-rate-limiter-memory` / `redis-rate-limiter` (with `windowSeconds`
227
+ # instead of `windowMs`). Switch via `adapters.rateLimiter = "memory" |
228
+ # "redis"` (see reference.conf). The Redis limiter's outage policy is its
229
+ # own key, `redis-rate-limiter.failMode` ("closed" by default;
230
+ # REDIS_RATE_LIMITER_FAIL_MODE sets it).
231
+ rateLimit {
232
+ login { windowMs = 900000, limit = 20 }
233
+ }
265
234
  }
266
235
 
267
- federations {
236
+ # The federations, in core's own section, keyed by the name each is reached
237
+ # at (/session/oauth/federation/<name>). A key of one is bound to the
238
+ # variable named after its path, CORE_FEDERATIONS_<NAME>_<KEY>.
239
+ core.federations {
268
240
  # Template ships a Google federation declaration disabled-by-default.
269
241
  # Operators enable + populate credentials via env vars.
270
242
  google {
271
243
  enabled = false
272
- enabled = ${?FEDERATIONS_GOOGLE_ENABLED}
273
- clientId = ${?FEDERATIONS_GOOGLE_CLIENT_ID}
274
- clientSecret = ${?FEDERATIONS_GOOGLE_CLIENT_SECRET}
244
+ enabled = ${?CORE_FEDERATIONS_GOOGLE_ENABLED}
245
+ clientId = ${?CORE_FEDERATIONS_GOOGLE_CLIENT_ID}
246
+ clientSecret = ${?CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET}
275
247
  callbackURL = "http://localhost:3000/session/oauth/federation/google/callback"
276
- callbackURL = ${?FEDERATIONS_GOOGLE_CALLBACK_URL}
248
+ callbackURL = ${?CORE_FEDERATIONS_GOOGLE_CALLBACK_URL}
277
249
 
278
250
  # Every URL a `redirect_to` query parameter may name on
279
251
  # /session/oauth/federation/google. Matching is EXACT — scheme, host, port,
@@ -282,8 +254,8 @@ federations {
282
254
  #
283
255
  # An empty list refuses every `redirect_to`, which is the right setting for
284
256
  # a deployment that does not use the parameter. It is NOT a way to allow
285
- # everything: before #278 an unset allowlist accepted any http(s) URL, which
286
- # made this an open redirect. Nothing falls back to that any more.
257
+ # everything: nothing falls back to accepting any http(s) URL, which would
258
+ # make this an open redirect.
287
259
  #
288
260
  # https is required, except for a loopback host (localhost, 127.0.0.0/8,
289
261
  # [::1]) — that carve-out is what lets a local dev front-end and a native
@@ -299,20 +271,20 @@ federations {
299
271
  # Cookie / session domain. When set, every non-loopback redirectAllowlist
300
272
  # entry must be inside it, checked at boot — an entry outside it fails
301
273
  # startup rather than sitting here looking effective.
302
- # sessionDomain = ${?FEDERATIONS_GOOGLE_SESSION_DOMAIN}
274
+ # sessionDomain = ${?CORE_FEDERATIONS_GOOGLE_SESSION_DOMAIN}
303
275
 
304
276
  # Post-callback bridge page. Required only if `redirect_to` is used at all:
305
277
  # the browser lands here as `<authCallbackUrl>?redirect_to=<encoded>`.
306
- # authCallbackUrl = ${?FEDERATIONS_GOOGLE_AUTH_CALLBACK_URL}
278
+ # authCallbackUrl = ${?CORE_FEDERATIONS_GOOGLE_AUTH_CALLBACK_URL}
307
279
 
308
280
  # Where the browser goes after a callback carrying no `redirect_to`.
309
- # clientUrl = ${?FEDERATIONS_GOOGLE_CLIENT_URL}
281
+ # clientUrl = ${?CORE_FEDERATIONS_GOOGLE_CLIENT_URL}
310
282
 
311
283
  # RFC 9207: Google always returns `iss` on the callback, so a callback
312
284
  # without one is refused. `false` is the way out if Google ever stops
313
285
  # sending it. It permits a missing `iss` only — one that is sent and is not
314
286
  # Google's is refused either way.
315
- # requireAuthorizationResponseIss = ${?FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS}
287
+ # requireAuthorizationResponseIss = ${?CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS}
316
288
 
317
289
  # Whether sign-in asks Google for a refresh token. Unset is "offline":
318
290
  # every sign-in shows Google's consent screen, because Google issues a
@@ -320,11 +292,11 @@ federations {
320
292
  # POST /oauth/federation/google/token can refresh with. "online" shows the
321
293
  # consent screen on the first sign-in only and gets no refresh token at
322
294
  # all — for a deployment that uses Google to sign in and nothing more.
323
- accessType = ${?FEDERATIONS_GOOGLE_ACCESS_TYPE}
295
+ accessType = ${?CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE}
324
296
  }
325
297
 
326
298
  # Any OpenID Connect provider (Okta, Entra ID, Auth0, Keycloak, your own
327
- # tenant) — `@o3co/auth-provider-federation-oidc` (#524). Disabled by
299
+ # tenant) — `@o3co/auth-provider-federation-oidc`. Disabled by
328
300
  # default; the env vars below switch this one instance on. The section
329
301
  # name is the federation name: the browser starts at
330
302
  # /session/oauth/federation/oidc and the identity handed to the Store is
@@ -333,19 +305,19 @@ federations {
333
305
  # another name (`okta { type = "oidc" ... }`) with its own callbackURL.
334
306
  oidc {
335
307
  enabled = false
336
- enabled = ${?FEDERATIONS_OIDC_ENABLED}
308
+ enabled = ${?CORE_FEDERATIONS_OIDC_ENABLED}
337
309
  type = "oidc"
338
310
 
339
311
  # Issuer identifier, exactly as the IdP writes it into `iss`. Discovery
340
312
  # runs at boot from <issuer>/.well-known/openid-configuration and a
341
313
  # failure refuses boot. https only (plain http on localhost for dev).
342
- issuer = ${?FEDERATIONS_OIDC_ISSUER}
343
- clientId = ${?FEDERATIONS_OIDC_CLIENT_ID}
314
+ issuer = ${?CORE_FEDERATIONS_OIDC_ISSUER}
315
+ clientId = ${?CORE_FEDERATIONS_OIDC_CLIENT_ID}
344
316
  # client_secret_basic. For private_key_jwt drop clientSecret and set
345
317
  # privateKey (a PEM PKCS#8 key, or { pem, kid, alg }) instead.
346
- clientSecret = ${?FEDERATIONS_OIDC_CLIENT_SECRET}
318
+ clientSecret = ${?CORE_FEDERATIONS_OIDC_CLIENT_SECRET}
347
319
  callbackURL = "http://localhost:3000/session/oauth/federation/oidc/callback"
348
- callbackURL = ${?FEDERATIONS_OIDC_CALLBACK_URL}
320
+ callbackURL = ${?CORE_FEDERATIONS_OIDC_CALLBACK_URL}
349
321
  scopes = ["openid", "profile", "email"]
350
322
 
351
323
  # Same rules as the Google block above: exact URLs, https except on a
@@ -360,132 +332,15 @@ federations {
360
332
  }
361
333
  }
362
334
 
363
- repositories {
364
- client {
365
- type = "yaml"
366
- type = ${?CLIENT_TYPE}
367
- yaml {
368
- path = "./config/clients.yaml"
369
- path = ${?CLIENT_PATH}
370
- }
371
- }
372
- user {
373
- type = "http"
374
- type = ${?CLIENT_USER_TYPE}
375
- yaml {
376
- path = "./config/users.yaml"
377
- }
378
- http {
379
- # Both URLs carry plaintext user credentials, so both must be absolute
380
- # https URLs. http:// is accepted only for a loopback host (localhost,
381
- # 127.0.0.0/8, [::1]); a private-range address or a container-network
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.
385
- authenticateUrl = ${?CLIENT_USER_AUTHENTICATE_URL}
386
- authenticateByTokenUrl = ${?CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL}
387
- # #482: optional. When set, a signed-in user can link a second federated
388
- # identity (GET /session/oauth/federation/<name>?link=1): the Store
389
- # receives { userId, provider, sub, token, claims } and decides — never on
390
- # an unverified or relay e-mail. Same https and no-redirect rule as the two URLs above.
391
- # Also declared in core's reference.conf, so a composition that does not
392
- # layer this file gets the same override.
393
- # Without it, link=1 is refused with link_unsupported.
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}
416
- # Positive integer, milliseconds, <= 2147483647. Boot fails otherwise —
417
- # including on a blank env override, which HOCON substitutes as "".
418
- timeout = 5000
419
- timeout = ${?CLIENT_USER_TIMEOUT}
420
- # Largest upstream response body accepted, in bytes. Positive integer.
421
- maxResponseBytes = 1048576
422
- maxResponseBytes = ${?CLIENT_USER_MAX_RESPONSE_BYTES}
423
- }
424
- }
425
- code {
426
- type = "redis"
427
- type = ${?CLIENT_CODE_TYPE}
428
- memory {
429
- defaultExpiresIn = 600
430
- defaultExpiresIn = ${?CLIENT_CODE_DEFAULT_EXPIRES_IN}
431
- }
432
- redis {
433
- defaultExpiresIn = 600
434
- defaultExpiresIn = ${?CLIENT_CODE_DEFAULT_EXPIRES_IN}
435
- endpointUri = ${?CLIENT_CODE_ENDPOINT_URI}
436
- password = ${?CLIENT_CODE_PASSWORD}
437
- }
438
- }
439
- }
440
-
441
- endpoints {
442
- login {
443
- url = "/login"
444
- url = ${?ENDPOINTS_LOGIN_URL}
445
- }
446
- # #527: the consent page a client that is not first-party is routed
447
- # through (`?challenge=<id>`). Deployment-owned, like the login page: it
448
- # reads GET /oauth/consent and posts to POST /oauth/consent.
449
- consent {
450
- url = "/consent"
451
- url = ${?ENDPOINTS_CONSENT_URL}
452
- }
453
- # IH-10: `client { url }` and `authCallback { url }` removed — no
454
- # production consumer reads them.
455
- }
456
-
457
- # #500: browser origins allowed to read the token, userinfo, revocation and
458
- # discovery/JWKS responses. Empty (the default) means CORS is off -- leave it
459
- # alone unless a browser app is served from a different origin than this
460
- # provider. See the library reference.conf for the full rules; the short
461
- # version is that matching is EXACT, so each entry is a bare origin with no
462
- # trailing slash, no path and no wildcard, and https is required off loopback.
463
- #
464
- # allowedOrigins = ["https://app.example.com", "http://localhost:5173"]
465
- #
466
- # The env-override line is repeated at this layer for the HOCON precedence
467
- # reason the oauth.grants block above explains: application.conf wins over
468
- # reference.conf, so without it CORS_ALLOWED_ORIGINS could not take effect.
469
- # It takes a comma-separated list.
470
- #
471
- # Not to be confused with `session.csrf.trustedOrigins`, which answers the
472
- # other question (#272).
473
- cors {
474
- allowedOrigins = []
475
- allowedOrigins = ${?CORS_ALLOWED_ORIGINS}
335
+ # The variables that set keys of the template's own modules, bound here, last,
336
+ # so each wins over a value this file sets above it. Keep this block last.
337
+ # config/reference.conf holds the defaults and binds the same variables.
338
+ http {
339
+ port = ${?HTTP_PORT}
340
+ trustProxy = ${?HTTP_TRUST_PROXY}
341
+ cors.allowedOrigins = ${?HTTP_CORS_ALLOWED_ORIGINS}
476
342
  }
477
-
478
- # D-2 v2 + Wave 5d: ioredis connection-config consumed by the shared
479
- # `standaloneRedisClientsModule` in `templates/standalone/src/modules.mts`.
480
- # One ioredis socket per replica backs ALL Redis-typed clients (RT family
481
- # + 4 user-session stores + rate limiter). Multi-replica deployments MUST
482
- # point this at a shared Redis 7.2+ instance — without it, each replica
483
- # stores RT families in its own ioredis connection against a local-only
484
- # Redis, defeating the cross-replica persistence purpose.
485
- refreshTokenFamilyStore {
486
- redis {
487
- url = "redis://localhost:6379"
488
- url = ${?REFRESH_TOKEN_FAMILY_STORE_REDIS_URL}
489
- password = ${?REFRESH_TOKEN_FAMILY_STORE_REDIS_PASSWORD}
490
- }
343
+ redis-clients {
344
+ url = ${?REDIS_CLIENTS_URL}
345
+ password = ${?REDIS_CLIENTS_PASSWORD}
491
346
  }