@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.2

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 (100) 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 -29
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +324 -169
  14. package/templates/standalone/README.md +618 -208
  15. package/templates/standalone/config/application.conf +211 -352
  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 +418 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +63 -32
  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__/adapters.test.mts +419 -0
  28. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
  33. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  34. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  35. package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
  36. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  37. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
  38. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
  39. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
  40. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
  41. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
  42. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  43. package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
  44. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  45. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  46. package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
  47. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  48. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  49. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  50. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  51. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
  52. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  53. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  54. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  55. package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
  56. package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
  57. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
  58. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  59. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
  60. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  61. package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
  62. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  63. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
  64. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  65. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  66. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
  67. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  68. package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
  69. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  70. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  71. package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
  72. package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
  73. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  74. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
  75. package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
  76. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
  77. package/templates/standalone/src/adapters.mts +162 -0
  78. package/templates/standalone/src/app.mts +65 -79
  79. package/templates/standalone/src/bootRefusal.mts +117 -0
  80. package/templates/standalone/src/buildModules.mts +220 -309
  81. package/templates/standalone/src/configPath.mts +449 -29
  82. package/templates/standalone/src/listen.mts +12 -18
  83. package/templates/standalone/src/logger.mts +31 -60
  84. package/templates/standalone/src/metrics.mts +24 -47
  85. package/templates/standalone/src/mfaSwitch.mts +285 -0
  86. package/templates/standalone/src/modules.mts +423 -629
  87. package/templates/standalone/src/rootRenames.mts +96 -0
  88. package/templates/standalone/src/routes.mts +8 -14
  89. package/templates/standalone/src/sections.mts +367 -0
  90. package/templates/standalone/src/shutdown.mts +45 -123
  91. package/templates/standalone/tests/index.test.js +6 -5
  92. package/templates/standalone/tests/package-lock.json +1615 -2223
  93. package/templates/standalone/tests/package.json +2 -2
  94. package/templates/standalone/tests/vitest.config.mjs +26 -0
  95. package/templates/standalone/vitest.config.mts +8 -7
  96. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  97. package/templates/versions.json +18 -15
  98. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
  99. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
  100. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
@@ -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 modules read these switches at boot, from the configuration boot parses;
32
+ # the template reads none of them before. This file writes the template's
33
+ # choices and binds each variable again: the env-override line is repeated
34
+ # after each value because a layer's own value wins over a variable bound
35
+ # below it.
41
36
  #
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}
37
+ # PKCE is mandatory for every authorization-code client and `S256` is the only
38
+ # method the provider accepts -- fixed policy (OAuth 2.1 §4.1.1 / RFC 9700
39
+ # §2.1.1), not a deployment setting. A legacy client that genuinely cannot
40
+ # compute SHA-256 is opted into `plain` one client at a time, with
41
+ # `allowPlainPkce: true` in clients.yaml.
42
+ oauth-session {
43
+ enabled = true
44
+ enabled = ${?OAUTH_SESSION_ENABLED}
45
+ }
46
+
47
+ oauth-authorization.grants {
48
+ authorizationCode {
49
+ enabled = true
50
+ enabled = ${?OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED}
51
+ }
52
+ refreshToken {
53
+ enabled = true
54
+ enabled = ${?OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED}
55
+ }
56
+ clientCredentials {
57
+ enabled = false
58
+ enabled = ${?OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED}
59
+ }
60
+ jwtBearer {
61
+ enabled = false
62
+ enabled = ${?OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED}
49
63
  }
50
64
  }
51
65
 
52
66
  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
67
+ # The Authentication Context Class References this deployment can
98
68
  # 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
69
+ # (`pwd` for a password login, `fed` for a federated login) — or to a list
70
+ # of such lists, any one of which satisfies it. `/authorize` answers
101
71
  # `acr_values` from this table alone — anything else is
102
72
  # `unmet_authentication_requirements` — and discovery advertises the keys.
103
73
  #
104
74
  # authorize.acrValues {
105
75
  # "urn:example:pwd" = ["pwd"]
106
- # "urn:example:mfa" = ["pwd", "mfa"]
107
76
  # }
108
77
  #
109
- # #267: /authorize refuses any client not marked `firstParty: true` in
78
+ # An entry nothing this deployment installs can satisfy is dropped at boot:
79
+ # not advertised, answered `unmet_authentication_requirements`, and logged
80
+ # once as `acr_value_unsatisfiable`. One that needs `fed` needs a
81
+ # federation installed; one that needs `mfa`, `hwk` or `swk` needs MFA
82
+ # installed (MFA_MODE, below), or a federation that trusts its upstream
83
+ # IdP's `amr`. One that lacks only a second factor while MFA is off is
84
+ # logged at info, not warn.
85
+ #
86
+ # While MFA_MODE installs MFA, the template adds `"urn:o3co:acr:mfa" =
87
+ # ["mfa"]`, met by a session with a verified second factor, unless this
88
+ # table writes that entry itself; with MFA off it adds nothing.
89
+ # Phishing-resistant authentication would be:
90
+ #
91
+ # "urn:o3co:acr:phr" = [["hwk"], ["swk"]]
92
+
93
+ # /authorize refuses any client not marked `firstParty: true` in
110
94
  # 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
113
- # 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
95
+ # opt-out: the one-time `allowUnmarkedClients` migration flag is removed,
96
+ # and a config or environment still setting it fails at boot: the oauth
97
+ # module refuses the key as removed, and an exported
98
+ # OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS as the variable of a removed key.
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,149 +112,144 @@ 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 {
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
- }
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 section's defaults and its other variables
158
+ # (FEDERATION_GRANTS_IDENTITY_LOOKUP, FEDERATION_GRANTS_CONSENT_URL) are the
159
+ # federation-grants package's reference.conf, and the Redis grant store's
160
+ # (REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE, "required" by default) the
161
+ # Redis package's: each is layered only where a module of its package is
162
+ # installed. The
163
+ # template reads `federation-grants.enabled` before it chooses its modules,
164
+ # from its own files, which is why this file binds FEDERATION_GRANTS_ENABLED;
165
+ # unset, it writes nothing, and the feature is off.
166
+ federation-grants.enabled = ${?FEDERATION_GRANTS_ENABLED}
183
167
 
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}
210
- }
211
-
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.
168
+ # The session store's section: the session cookie and the store
169
+ # express-session keeps sessions in. A memory store declares itself
170
+ # replica-unsafe from this section.
171
+ session-store {
172
+ secret = ${?SESSION_STORE_SECRET}
173
+ # A `__Host-` name requires Secure, Path=/ and no Domain attribute. If
174
+ # SESSION_STORE_SECURE=false or SESSION_STORE_DOMAIN is set, also set
175
+ # SESSION_STORE_NAME to a name without the prefix.
217
176
  name = "__Host-auth.session"
218
- name = ${?SESSION_NAME}
177
+ name = ${?SESSION_STORE_NAME}
219
178
  maxAge = 3600000
220
- maxAge = ${?SESSION_MAX_AGE}
179
+ maxAge = ${?SESSION_STORE_MAX_AGE}
221
180
  secure = true
222
- secure = ${?SESSION_SECURE}
181
+ secure = ${?SESSION_STORE_SECURE}
223
182
  sameSite = "lax"
224
- sameSite = ${?SESSION_SAME_SITE}
183
+ sameSite = ${?SESSION_STORE_SAME_SITE}
225
184
  domain = null
226
- domain = ${?SESSION_DOMAIN}
227
- # #405: every URL `POST /session/login` may accept as `redirect_to`, matched
185
+ domain = ${?SESSION_STORE_DOMAIN}
186
+ storage {
187
+ type = "redis"
188
+ type = ${?SESSION_STORE_STORAGE_TYPE}
189
+ redis {
190
+ url = "redis://localhost:6379"
191
+ url = ${?SESSION_STORE_STORAGE_REDIS_URL}
192
+ password = ${?SESSION_STORE_STORAGE_REDIS_PASSWORD}
193
+ }
194
+ }
195
+ }
196
+
197
+ # The session module's section: the login routes' settings.
198
+ session {
199
+ # Every URL `POST /session/login` may accept as `redirect_to`, matched
228
200
  # EXACTLY -- scheme, host, port, path and query all significant. Empty (the
229
201
  # default) refuses every `redirect_to`, so leave it alone unless your login
230
202
  # page actually posts one.
231
203
  #
232
204
  # redirectAllowlist = ["https://app.example.com/welcome"]
233
205
  #
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).
206
+ # `http://` is accepted for loopback hosts only. With `session-store.domain`
207
+ # set above, an entry outside it fails boot rather than being silently
208
+ # ignored. The federation flow has a separate list per provider (see
209
+ # `core.federations` below).
237
210
  redirectAllowlist = []
238
- storage {
239
- type = "redis"
240
- type = ${?SESSION_STORAGE_TYPE}
241
- redis {
242
- url = "redis://localhost:6379"
243
- url = ${?SESSION_STORAGE_REDIS_URL}
244
- password = ${?SESSION_STORAGE_REDIS_PASSWORD}
245
- }
211
+ # POST /session/login's own attempt limit (brute-force protection), in
212
+ # windowMs (milliseconds), counted on the attempt counter
213
+ # (`adapters.attemptCounter`); kept in this layer so operators can tweak
214
+ # it. No rate limiter changes it. It does NOT govern OAuth endpoint rate
215
+ # limiting: the `rateLimiter` component
216
+ # (memoryRateLimiterModule or redisRateLimiterModule) reads its own section,
217
+ # `core-rate-limiter-memory` / `redis-rate-limiter` (with `windowSeconds`
218
+ # instead of `windowMs`). Switch via `adapters.rateLimiter = "memory" |
219
+ # "redis"` (see reference.conf). The Redis limiter's outage policy is its
220
+ # own key, `redis-rate-limiter.failMode` ("closed" by default;
221
+ # REDIS_RATE_LIMITER_FAIL_MODE sets it).
222
+ rateLimit {
223
+ login { windowMs = 900000, limit = 20 }
246
224
  }
247
225
  }
248
226
 
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}
265
- }
227
+ # The MFA routes' budget, 60 per 300 s, is config/reference.conf's, given to
228
+ # both limiters so that switching `adapters.rateLimiter` keeps it. Change it
229
+ # for the limiter this deployment wires, in its own section:
230
+ #
231
+ # redis-rate-limiter.limits.mfa { limit = 30, windowSeconds = 300 }
266
232
 
267
- federations {
233
+ # The federations, in core's own section, keyed by the name each is reached
234
+ # at (/session/oauth/federation/<name>). A key of one is bound to the
235
+ # variable named after its path, CORE_FEDERATIONS_<NAME>_<KEY>. Each entry
236
+ # names its `type`, and boot hands an enabled entry to the module that handles
237
+ # that type: the template loads the `google` and `oidc` types, so an entry of
238
+ # either type under any name is a federation of its own. An entry without a
239
+ # `type`, enabled or not, refuses the boot, and so does an enabled entry of a
240
+ # type no loaded module handles.
241
+ core.federations {
268
242
  # Template ships a Google federation declaration disabled-by-default.
269
- # Operators enable + populate credentials via env vars.
243
+ # Operators enable + populate credentials via env vars. A second Google
244
+ # client is another entry with `type = "google"` and its own callbackURL.
270
245
  google {
271
246
  enabled = false
272
- enabled = ${?FEDERATIONS_GOOGLE_ENABLED}
273
- clientId = ${?FEDERATIONS_GOOGLE_CLIENT_ID}
274
- clientSecret = ${?FEDERATIONS_GOOGLE_CLIENT_SECRET}
247
+ enabled = ${?CORE_FEDERATIONS_GOOGLE_ENABLED}
248
+ type = "google"
249
+ clientId = ${?CORE_FEDERATIONS_GOOGLE_CLIENT_ID}
250
+ clientSecret = ${?CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET}
275
251
  callbackURL = "http://localhost:3000/session/oauth/federation/google/callback"
276
- callbackURL = ${?FEDERATIONS_GOOGLE_CALLBACK_URL}
252
+ callbackURL = ${?CORE_FEDERATIONS_GOOGLE_CALLBACK_URL}
277
253
 
278
254
  # Every URL a `redirect_to` query parameter may name on
279
255
  # /session/oauth/federation/google. Matching is EXACT — scheme, host, port,
@@ -282,8 +258,8 @@ federations {
282
258
  #
283
259
  # An empty list refuses every `redirect_to`, which is the right setting for
284
260
  # 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.
261
+ # everything: nothing falls back to accepting any http(s) URL, which would
262
+ # make this an open redirect.
287
263
  #
288
264
  # https is required, except for a loopback host (localhost, 127.0.0.0/8,
289
265
  # [::1]) — that carve-out is what lets a local dev front-end and a native
@@ -299,20 +275,20 @@ federations {
299
275
  # Cookie / session domain. When set, every non-loopback redirectAllowlist
300
276
  # entry must be inside it, checked at boot — an entry outside it fails
301
277
  # startup rather than sitting here looking effective.
302
- # sessionDomain = ${?FEDERATIONS_GOOGLE_SESSION_DOMAIN}
278
+ # sessionDomain = ${?CORE_FEDERATIONS_GOOGLE_SESSION_DOMAIN}
303
279
 
304
280
  # Post-callback bridge page. Required only if `redirect_to` is used at all:
305
281
  # the browser lands here as `<authCallbackUrl>?redirect_to=<encoded>`.
306
- # authCallbackUrl = ${?FEDERATIONS_GOOGLE_AUTH_CALLBACK_URL}
282
+ # authCallbackUrl = ${?CORE_FEDERATIONS_GOOGLE_AUTH_CALLBACK_URL}
307
283
 
308
284
  # Where the browser goes after a callback carrying no `redirect_to`.
309
- # clientUrl = ${?FEDERATIONS_GOOGLE_CLIENT_URL}
285
+ # clientUrl = ${?CORE_FEDERATIONS_GOOGLE_CLIENT_URL}
310
286
 
311
287
  # RFC 9207: Google always returns `iss` on the callback, so a callback
312
288
  # without one is refused. `false` is the way out if Google ever stops
313
289
  # sending it. It permits a missing `iss` only — one that is sent and is not
314
290
  # Google's is refused either way.
315
- # requireAuthorizationResponseIss = ${?FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS}
291
+ # requireAuthorizationResponseIss = ${?CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS}
316
292
 
317
293
  # Whether sign-in asks Google for a refresh token. Unset is "offline":
318
294
  # every sign-in shows Google's consent screen, because Google issues a
@@ -320,11 +296,11 @@ federations {
320
296
  # POST /oauth/federation/google/token can refresh with. "online" shows the
321
297
  # consent screen on the first sign-in only and gets no refresh token at
322
298
  # all — for a deployment that uses Google to sign in and nothing more.
323
- accessType = ${?FEDERATIONS_GOOGLE_ACCESS_TYPE}
299
+ accessType = ${?CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE}
324
300
  }
325
301
 
326
302
  # Any OpenID Connect provider (Okta, Entra ID, Auth0, Keycloak, your own
327
- # tenant) — `@o3co/auth-provider-federation-oidc` (#524). Disabled by
303
+ # tenant) — `@o3co/auth-provider-federation-oidc`. Disabled by
328
304
  # default; the env vars below switch this one instance on. The section
329
305
  # name is the federation name: the browser starts at
330
306
  # /session/oauth/federation/oidc and the identity handed to the Store is
@@ -333,19 +309,19 @@ federations {
333
309
  # another name (`okta { type = "oidc" ... }`) with its own callbackURL.
334
310
  oidc {
335
311
  enabled = false
336
- enabled = ${?FEDERATIONS_OIDC_ENABLED}
312
+ enabled = ${?CORE_FEDERATIONS_OIDC_ENABLED}
337
313
  type = "oidc"
338
314
 
339
315
  # Issuer identifier, exactly as the IdP writes it into `iss`. Discovery
340
316
  # runs at boot from <issuer>/.well-known/openid-configuration and a
341
317
  # failure refuses boot. https only (plain http on localhost for dev).
342
- issuer = ${?FEDERATIONS_OIDC_ISSUER}
343
- clientId = ${?FEDERATIONS_OIDC_CLIENT_ID}
318
+ issuer = ${?CORE_FEDERATIONS_OIDC_ISSUER}
319
+ clientId = ${?CORE_FEDERATIONS_OIDC_CLIENT_ID}
344
320
  # client_secret_basic. For private_key_jwt drop clientSecret and set
345
321
  # privateKey (a PEM PKCS#8 key, or { pem, kid, alg }) instead.
346
- clientSecret = ${?FEDERATIONS_OIDC_CLIENT_SECRET}
322
+ clientSecret = ${?CORE_FEDERATIONS_OIDC_CLIENT_SECRET}
347
323
  callbackURL = "http://localhost:3000/session/oauth/federation/oidc/callback"
348
- callbackURL = ${?FEDERATIONS_OIDC_CALLBACK_URL}
324
+ callbackURL = ${?CORE_FEDERATIONS_OIDC_CALLBACK_URL}
349
325
  scopes = ["openid", "profile", "email"]
350
326
 
351
327
  # Same rules as the Google block above: exact URLs, https except on a
@@ -360,132 +336,15 @@ federations {
360
336
  }
361
337
  }
362
338
 
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}
339
+ # The variables that set keys of the template's own modules, bound here, last,
340
+ # so each wins over a value this file sets above it. Keep this block last.
341
+ # config/reference.conf holds the defaults and binds the same variables.
342
+ http {
343
+ port = ${?HTTP_PORT}
344
+ trustProxy = ${?HTTP_TRUST_PROXY}
345
+ cors.allowedOrigins = ${?HTTP_CORS_ALLOWED_ORIGINS}
476
346
  }
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
- }
347
+ redis-clients {
348
+ url = ${?REDIS_CLIENTS_URL}
349
+ password = ${?REDIS_CLIENTS_PASSWORD}
491
350
  }