standard_id 0.52.0 → 0.54.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.
@@ -0,0 +1,586 @@
1
+ # StandardId configuration
2
+ # Generated by: rails g standard_id:install --with-reference
3
+ #
4
+ # The annotated reference: every commonly changed setting with its default.
5
+ # All configuration fields are defined in lib/standard_id/config/schema.rb.
6
+ # Commented-out lines show defaults; uncomment and edit to override. Once you
7
+ # know what you need, delete the commented lines: an initializer that holds
8
+ # only your overrides is easier to review than one that holds every default.
9
+
10
+ StandardId.configure do |c|
11
+ # An Inertia app whose users sign in passwordlessly or socially can start
12
+ # from a preset instead of setting each option below. :inertia_web sets
13
+ # use_inertia, alias_current_user, web.signup/password_login/password_reset
14
+ # = false and oauth.discovery_endpoint_base = :request. Anything you assign
15
+ # yourself wins, before or after this line.
16
+ # c.preset :inertia_web
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Required
20
+ # ---------------------------------------------------------------------------
21
+
22
+ # The ActiveRecord class that represents a user/account in your app. Must
23
+ # include StandardId::AccountAssociations (see post-install checklist).
24
+ # Default: "User"
25
+ c.account_class_name = "User"
26
+ # The enum key StandardId::AccountStatus uses for a deactivated account
27
+ # (default :inactive). Set it to the value your status column already
28
+ # carries, e.g. :suspended, to adopt the concern without renaming it.
29
+ # c.inactive_account_status = :inactive
30
+
31
+ # JWT "iss" claim — typically your application's canonical base URL. When
32
+ # set, tokens embed it and verification enforces it. Strongly recommended
33
+ # for production; otherwise the engine logs a warning on boot.
34
+ # Default: nil
35
+ # c.issuer = "https://auth.example.com"
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Discovery documents (/.well-known/openid-configuration and
39
+ # /.well-known/oauth-authorization-server)
40
+ # ---------------------------------------------------------------------------
41
+ # READ THIS IF YOU MOUNT ApiEngine UNDER A PREFIX (/api, /api/v1, ...).
42
+ #
43
+ # The issuer and the endpoint base are different things. `issuer` is a stable
44
+ # security identifier (RFC 8414 §2) that clients compare byte-for-byte with the
45
+ # URL they used for discovery and with the `iss` claim of issued tokens. It is
46
+ # never derived from the request and cannot be overridden.
47
+ #
48
+ # `discovery_endpoint_base` is where /authorize, /oauth/token etc. ACTUALLY
49
+ # are. It defaults to the issuer, which is right only if your issuer carries
50
+ # the mount path (e.g. "https://host/auth/api"). If it does not, the document
51
+ # advertises endpoints at the bare origin and every one of them 404s.
52
+ #
53
+ # nil (default) — the issuer
54
+ # :request — request.base_url + the detected mount path <- prefixed mounts
55
+ # "https://..." — verbatim
56
+ # callable — receives (request:); for a proxy that rewrites base_url, or
57
+ # an app mounting ApiEngine more than once
58
+ #
59
+ # Default: nil
60
+ # c.oauth.discovery_endpoint_base = :request
61
+
62
+ # Members that cannot be derived. Values static or callable; a callable gets
63
+ # { origin:, endpoint_base:, issuer:, request: } (origin has no path). A nil
64
+ # value REMOVES the member rather than emitting null. Setting `issuer` raises.
65
+ # Default: {}
66
+ # c.oauth.discovery_metadata_overrides = {
67
+ # # a host-owned shim that injects the audience before handing off to the
68
+ # # engine's /authorize
69
+ # authorization_endpoint: ->(ctx) { "#{ctx[:origin]}/oauth/authorize" },
70
+ # # narrower than everything the server can mint
71
+ # scopes_supported: %w[mcp mcp:read],
72
+ # # MUST mirror your dynamic-registration policy, or spec-following clients
73
+ # # register in a way that policy then rejects
74
+ # token_endpoint_auth_methods_supported: %w[none],
75
+ # # omit a member entirely
76
+ # userinfo_endpoint: nil
77
+ # }
78
+ #
79
+ # RFC 8615 clients probe the ORIGIN ROOT, which is outside every engine mount,
80
+ # so those routes must be drawn in config/routes.rb — the gem cannot:
81
+ #
82
+ # standard_id_well_known_routes at: "/api" # your ApiEngine mount path
83
+ #
84
+ # That draws the root form AND the RFC 8414 §3.1 path-inserted form
85
+ # (/.well-known/oauth-authorization-server/api). The second looks redundant
86
+ # and is not: it is the URL Claude Code actually requests against a
87
+ # path-carrying issuer. Accepts only:/except: (:oauth_authorization_server,
88
+ # :openid_configuration, :jwks) — e.g. `only: :jwks` if you keep your own
89
+ # metadata controller. See docs/MIGRATION_GUIDE.md.
90
+
91
+ # Login URL used for redirects when authentication is required but missing.
92
+ # Default: "/login"
93
+ # c.login_url = "/login"
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Infrastructure
97
+ # ---------------------------------------------------------------------------
98
+
99
+ # Cache backend for rate limits, JWKS, etc. Falls back to Rails.cache.
100
+ # Default: Rails.cache
101
+ # c.cache_store = Rails.cache
102
+
103
+ # Logger used by StandardId. Falls back to Rails.logger.
104
+ # Default: Rails.logger
105
+ # c.logger = Rails.logger
106
+
107
+ # Layout used by built-in ERB web views. Ignored when use_inertia is true.
108
+ # Default: nil (StandardId's packaged layout)
109
+ # c.web_layout = "application"
110
+
111
+ # The class StandardId's mailers inherit from. Set it to your own mailer base
112
+ # so OTP and password-reset mail picks up your layout, helpers and locale.
113
+ # Default: "ActionMailer::Base"
114
+ # c.mailer_parent_class = "ApplicationMailer"
115
+
116
+ # Expose current_account as current_user (useful for ActionPolicy / Pundit).
117
+ # Default: false
118
+ # c.alias_current_user = true
119
+
120
+ # Opt StandardId's OWN associations out of an app-wide
121
+ # `strict_loading_by_default = true`.
122
+ #
123
+ # Affects Account#identifiers/credentials/sessions/refresh_tokens/
124
+ # client_applications, StandardId::Session#refresh_tokens and
125
+ # StandardId::Identifier#credentials. Set it to false if strict loading is on
126
+ # app-wide and you don't want to eager-load the gem's internals at every call
127
+ # site (the gem lazy-loads some of them itself).
128
+ #
129
+ # Tri-state, and nil is NOT the same as false:
130
+ # nil — declare no `strict_loading:` option at all; the associations
131
+ # inherit your model's setting, exactly as before this option
132
+ # existed. This is the default and preserves existing behaviour.
133
+ # false — opt out: these associations may lazy-load even with strict
134
+ # loading on app-wide.
135
+ # true — opt in: force strict loading on them even if it is off app-wide.
136
+ #
137
+ # Must be set HERE, in an initializer — not in an `after_initialize` block.
138
+ # The associations read it when your Account class body runs, so a later
139
+ # assignment has no effect. StandardId raises a ConfigurationError at boot if
140
+ # it detects that, rather than letting it fail silently.
141
+ #
142
+ # Default: nil
143
+ # c.association_strict_loading = false
144
+
145
+ # Default scope applied when loading the current account — eager-load the
146
+ # associations your views/controllers touch to avoid N+1s.
147
+ # Default: nil
148
+ # c.account_scope = ->(scope) { scope.includes(:profiles, :identifiers) }
149
+
150
+ # Inertia.js support (requires the inertia_rails gem). When enabled, web
151
+ # controllers render Inertia components instead of ERB views. Provide the
152
+ # matching frontend components (e.g. pages/auth/login/show.tsx).
153
+ # Defaults: use_inertia = false, inertia_component_namespace = "standard_id"
154
+ # c.use_inertia = true
155
+ # c.inertia_component_namespace = "auth"
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Web engine feature toggles
159
+ # ---------------------------------------------------------------------------
160
+ # Each toggle enables or disables a built-in WebEngine route group. Turn
161
+ # off features your app handles itself or does not expose.
162
+
163
+ # Default: true — enables /login, /logout and related password routes.
164
+ # c.web.password_login = true
165
+
166
+ # Default: true — enables /signup.
167
+ # c.web.signup = true
168
+
169
+ # Default: false — enables OTP (email/SMS) login at /passwordless.
170
+ # c.web.passwordless_login = false
171
+
172
+ # Default: false — allows new account registration via passwordless OTP.
173
+ # c.web.passwordless_registration = false
174
+
175
+ # Default: true — enables /auth/:provider social login routes.
176
+ # c.web.social_login = true
177
+
178
+ # Default: true — enables /password/reset flow.
179
+ # c.web.password_reset = true
180
+
181
+ # Default: true — enables email verification endpoints.
182
+ # c.web.email_verification = true
183
+
184
+ # Default: true — enables phone verification endpoints.
185
+ # c.web.phone_verification = true
186
+
187
+ # Default: true — enables /sessions management UI.
188
+ # c.web.sessions_management = true
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Session lifetimes (seconds)
192
+ # ---------------------------------------------------------------------------
193
+
194
+ # Default: 86_400 — 24 hours for browser (cookie) sessions.
195
+ # c.session.browser_session_lifetime = 86_400
196
+
197
+ # Default: 2_592_000 — 30 days for "remember me" cookies.
198
+ # c.session.browser_session_remember_me_lifetime = 2_592_000
199
+
200
+ # Default: 2_592_000 — 30 days for API / mobile device sessions.
201
+ # c.session.device_session_lifetime = 2_592_000
202
+
203
+ # Default: 7_776_000 — 90 days for service-to-service sessions.
204
+ # c.session.service_session_lifetime = 7_776_000
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Password policy
208
+ # ---------------------------------------------------------------------------
209
+
210
+ # Default: 8
211
+ # c.password.minimum_length = 8
212
+
213
+ # Default: true
214
+ # c.password.require_special_chars = true
215
+
216
+ # Default: true
217
+ # c.password.require_uppercase = true
218
+
219
+ # Default: true
220
+ # c.password.require_numbers = true
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Passwordless (OTP)
224
+ # ---------------------------------------------------------------------------
225
+
226
+ # OTP email delivery mode:
227
+ # :custom — host app handles delivery via event subscriber (default)
228
+ # :built_in — engine sends via PasswordlessMailer
229
+ # Default: :custom
230
+ # c.passwordless.delivery = :built_in
231
+ # c.passwordless.mailer_from = "noreply@example.com"
232
+ # c.passwordless.mailer_subject = "Your sign-in code"
233
+
234
+ # Default: 600 (10 minutes) — TTL of OTP codes.
235
+ # c.passwordless.code_ttl = 600
236
+
237
+ # Default: 6 — length of generated OTP codes (digits). Clamped to 4..10.
238
+ # Codes may be zero-padded (e.g. "000123") so host apps that display or
239
+ # parse codes must treat them as strings, not integers. 8+ is recommended
240
+ # for security-sensitive deployments.
241
+ # c.passwordless.code_length = 6
242
+
243
+ # Per-challenge global attempt ceiling (across all IPs). When reached the
244
+ # challenge is burned so further submissions fail fast. Distinct from the
245
+ # per-IP rate limit (c.rate_limits.otp_verify_per_ip) — this defends
246
+ # against distributed brute-force against a single challenge.
247
+ # Default: nil — falls back to :max_attempts (default 3) for backwards
248
+ # compatibility, so the effective default ceiling is 3.
249
+ # c.passwordless.max_attempts_per_challenge = 3
250
+
251
+ # Default: 3 — deprecated alias for :max_attempts_per_challenge. Retained
252
+ # for backwards compatibility; new installs should set the newer key.
253
+ # c.passwordless.max_attempts = 3
254
+
255
+ # Default: 30 — OTP-resend cooldown: minimum seconds before the same target
256
+ # can request another code. Enforced in the passwordless start path; a resend
257
+ # inside the window is rejected (the already-sent code stays valid). Set to 0
258
+ # to disable the cooldown.
259
+ # c.passwordless.retry_delay = 30
260
+
261
+ # Custom username validator; return nil/false to accept, or an error message
262
+ # string to reject. Useful for domain allow-lists or truemail integration.
263
+ # Default: nil
264
+ # c.passwordless.username_validator = ->(username, connection_type) {
265
+ # "Invalid email" unless TruemailValidator.valid?(username)
266
+ # }
267
+
268
+ # Custom account factory invoked during passwordless registration.
269
+ # Receives (identifier:, params:, request:) and must return an Account.
270
+ # Default: nil (uses built-in find_or_create logic)
271
+ # c.passwordless.account_factory = ->(identifier:, params:, request:) {
272
+ # Account.create!(email: identifier.value)
273
+ # }
274
+
275
+ # With c.passwordless.delivery = :custom, deliver codes from an event
276
+ # subscriber (c.passwordless_email_sender / _sms_sender were removed in
277
+ # 0.43). It runs synchronously in the request, so I18n.locale etc. are still
278
+ # available. The same subscriber delivers Otp.issue(delivery: :custom) codes.
279
+ #
280
+ # StandardId::Events.subscribe(StandardId::Events::PASSWORDLESS_CODE_GENERATED) do |event|
281
+ # next if event[:skip_sender] # Otp.issue(delivery: :manual)
282
+ # case event[:channel]
283
+ # when "email" then PasswordlessMailer.with(code: event[:code_challenge].code, to: event[:identifier]).deliver_later
284
+ # when "sms" then SmsProvider.send_code(phone: event[:identifier], code: event[:code_challenge].code)
285
+ # end
286
+ # end
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # API engine — OAuth / JWT
290
+ # ---------------------------------------------------------------------------
291
+
292
+ # Default: 3600 — access token lifetime in seconds (1 hour).
293
+ # c.oauth.default_token_lifetime = 3600
294
+
295
+ # Default: 2_592_000 — refresh token lifetime in seconds (30 days).
296
+ # c.oauth.refresh_token_lifetime = 2_592_000
297
+
298
+ # Per-grant overrides. Keys map to OAuth grant types.
299
+ # Default: {}
300
+ # c.oauth.token_lifetimes = {
301
+ # password: 8.hours,
302
+ # client_credentials: 24.hours,
303
+ # social: 24.hours
304
+ # }
305
+
306
+ # Audiences accepted in JWT "aud" claim. An empty array disables audience
307
+ # validation entirely — set this in production.
308
+ # Default: []
309
+ # c.oauth.allowed_audiences = %w[web mobile admin]
310
+
311
+ # Scope-to-claims mapping — when a scope is granted, every listed claim is
312
+ # resolved via claim_resolvers below and embedded in the access token.
313
+ # Default: {}
314
+ # c.oauth.scope_claims = {
315
+ # profile: %i[email display_name]
316
+ # }
317
+
318
+ # Claim resolvers: one per claim listed in scope_claims. Each is a callable
319
+ # receiving keyword args — pick only the ones you need.
320
+ # Available kwargs: account:, client:, request:, audience:, session:
321
+ # Default: {}
322
+ # c.oauth.claim_resolvers = {
323
+ # email: ->(account:) { account.email },
324
+ # display_name: ->(account:, client:) { "#{account.name} for #{client.client_id}" },
325
+ # channel_id: ->(client:) { client.owner_type == "Channel" ? client.owner_id : nil }
326
+ # }
327
+ # c.oauth.allowed_audiences = %w[web mobile admin] # Empty = no validation
328
+ #
329
+ # Audience → profile-type binding. When set, StandardId::AudienceVerification
330
+ # will additionally verify that the authenticated account holds a profile of
331
+ # the configured type for the matched audience. Values may be a single
332
+ # profile-type string or an Array<String> for multi-type audiences.
333
+ # c.oauth.audience_profile_types = {
334
+ # "admin_kit" => "PlatformProfile",
335
+ # "companion_kit" => "DeviceUserProfile",
336
+ # "harness" => ["PlatformProfile", "DeviceUserProfile"]
337
+ # }
338
+ #
339
+ # Optional resolver for picking the profile used for a given audience. Called
340
+ # with (account:, audience:, profile_types:). When nil (default), the gem
341
+ # looks up `account.profiles` and picks one whose `profileable_type` is in
342
+ # the configured profile_types (preferring an `active?` record).
343
+ # c.oauth.audience_profile_resolver = ->(account:, audience:, profile_types:) {
344
+ # account.profiles.active.find_by(profileable_type: profile_types)
345
+ # }
346
+ #
347
+ # Custom claims added to every access token (independent of scopes).
348
+ # Receives keyword arguments: account:, client:, request:, audience:
349
+ # Must return a Hash. Reserved JWT keys (sub, exp, iat, etc.) are excluded.
350
+ # c.oauth.custom_claims = ->(account:, **) { { channel_id: account.channel_id } }
351
+
352
+ # RFC 7662 token introspection (POST /oauth/introspect).
353
+ #
354
+ # Off by default: the endpoint returns 404 and `introspection_endpoint` is not
355
+ # advertised in the discovery documents. An endpoint that answers questions
356
+ # about other people's tokens is not something to expose by accident.
357
+ #
358
+ # Confidential clients only — the caller presents client_id + client_secret
359
+ # (HTTP Basic or form body). Every failure renders `{"active": false}` with no
360
+ # other members, per RFC 7662 §2.2, including a tripped rate limit (a 429
361
+ # would distinguish "throttled" from "token invalid" and make the limiter a
362
+ # token-validity oracle).
363
+ #
364
+ # KNOW THE LIMIT BEFORE BUILDING AN AUTHORIZATION GATE ON THIS. Access tokens
365
+ # are stateless — never persisted, and carrying no `sid` — so a revoked
366
+ # session's access token introspects as `active: true` until its `exp`.
367
+ # Introspection answers "did we mint this, and is it unexpired?", NOT "is it
368
+ # still honoured?". The mitigation is a short access-token lifetime. Refresh
369
+ # tokens ARE persisted and are checked against the row, so a revoked refresh
370
+ # token introspects as inactive immediately.
371
+ #
372
+ # Default: false
373
+ # c.oauth.introspection_enabled = true
374
+
375
+ # JWT signing configuration.
376
+ #
377
+ # Default: :hs256 symmetric signing with Rails.application.secret_key_base.
378
+ # For asymmetric signing, set signing_key and expose the public key via the
379
+ # JWKS endpoint at /.well-known/jwks.json.
380
+ #
381
+ # Algorithms: :hs256/:hs384/:hs512 (HMAC), :rs256/:rs384/:rs512 (RSA),
382
+ # :es256/:es384/:es512 (ECDSA — recommended for new projects).
383
+ #
384
+ # Generate keys:
385
+ # ES256: openssl ecparam -name prime256v1 -genkey -noout -out signing_key.pem
386
+ # RS256: openssl genrsa -out signing_key.pem 2048
387
+ #
388
+ # c.oauth.signing_algorithm = :es256
389
+ # c.oauth.signing_key = Rails.application.credentials.dig(:standard_id, :signing_key)
390
+ #
391
+ # Key rotation — move the previous signing_key here so tokens issued under
392
+ # it still verify during the grace period. Remove once the longest token
393
+ # lifetime has elapsed.
394
+ # c.oauth.previous_signing_keys = [
395
+ # Rails.application.credentials.dig(:standard_id, :previous_signing_key)
396
+ # ]
397
+ # # Cross-algorithm rotation:
398
+ # c.oauth.previous_signing_keys = [
399
+ # { key: Rails.application.credentials.dig(:standard_id, :old_rsa_key), algorithm: :rs256 }
400
+ # ]
401
+
402
+ # ---------------------------------------------------------------------------
403
+ # Lifecycle hooks (WebEngine, synchronous)
404
+ # ---------------------------------------------------------------------------
405
+
406
+ # Called after credential verification, BEFORE session creation.
407
+ # Receives: (account, request, context)
408
+ # context = { mechanism:, provider:, first_sign_in: }
409
+ # Return nil/truthy to proceed, or { error: "message" } to reject.
410
+ # Default: nil
411
+ # c.before_sign_in = ->(account, request, context) {
412
+ # { error: "Access restricted" } unless account.allowed?
413
+ # }
414
+
415
+ # Called after successful sign-in, before redirect.
416
+ # Receives: (account, request, context)
417
+ # context = { first_sign_in:, mechanism:, provider:, session: }
418
+ # Return nil (use default redirect) or a path string to override.
419
+ # Raise StandardId::AuthenticationDenied to reject after sign-in.
420
+ # Default: nil
421
+ # c.after_sign_in = ->(account, request, context) {
422
+ # context[:first_sign_in] ? "/onboarding" : nil
423
+ # }
424
+
425
+ # Decides which authentication methods may sign an account in. Consulted in
426
+ # EVERY web and API flow that establishes a new authentication, after the
427
+ # credential is proven and before any session or token is created.
428
+ # Receives any subset of: account:, auth_method: (:password, :passwordless,
429
+ # :social, :remember_me, :unspecified), provider:, request:, flow:
430
+ # Return truthy to allow, false/nil to refuse, or raise
431
+ # StandardId::LoginMethodDenied.new("message") to refuse with a message.
432
+ # Default: nil (every method allowed)
433
+ # c.login_method_policy = ->(account:, auth_method:, provider:) {
434
+ # next true unless account.staff?
435
+ # auth_method == :social && provider == "void_which_binds"
436
+ # }
437
+
438
+ # Called after a new account is created via any mechanism.
439
+ # Receives: (account, request, context)
440
+ # context = { mechanism:, provider: }
441
+ # Use for profile provisioning, welcome emails, analytics, etc.
442
+ # Default: nil
443
+ # c.after_account_created = ->(account, request, context) {
444
+ # UserProfileProvisioning.call(account, source: context[:mechanism])
445
+ # }
446
+
447
+ # ---------------------------------------------------------------------------
448
+ # Events
449
+ # ---------------------------------------------------------------------------
450
+
451
+ # Emit internal lifecycle events through Rails.logger.
452
+ # Default: false
453
+ # c.events.enable_logging = false
454
+
455
+ # For audit logging, use the standard_audit gem — see the README.
456
+
457
+ # ---------------------------------------------------------------------------
458
+ # Social login
459
+ # ---------------------------------------------------------------------------
460
+ # These fields are declared by the PROVIDER PLUGIN GEMS, not by standard_id
461
+ # itself, so each line below requires the matching gem in your Gemfile:
462
+ # `standard_id-google` for the google_* fields, `standard_id-apple` for the
463
+ # apple_* fields. Uncommenting a line without its gem raises
464
+ # StandardId::ConfigurationError ("Unknown field ... for scope 'social'") —
465
+ # the field genuinely does not exist, because nothing declared it.
466
+ #
467
+ # With the gem present, writing them here — in a plain initializer, in the
468
+ # `social` scope, exactly as shown — is correct and supported. standard_id
469
+ # >= 0.33.0 declares every loaded provider's fields before initializers run.
470
+ # On 0.32.0 and earlier these same lines raised, because the fields were only
471
+ # declared later from the plugin's Railtie, and apps worked around it by
472
+ # wrapping the writes in `Rails.application.config.after_initialize`. That
473
+ # wrapper is no longer needed, and it still works if you have one.
474
+ #
475
+ # ENV defaults (standard_id >= 0.42): every provider field you do not assign
476
+ # falls back to the ENV variable named after it, upper-cased — so with the
477
+ # canonical variables below set, you need none of these lines. Assign a
478
+ # field only to read it from somewhere else (a differently named variable,
479
+ # Rails credentials); an explicit assignment, even of nil, always wins.
480
+ #
481
+ # GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
482
+ # APPLE_CLIENT_ID, APPLE_MOBILE_CLIENT_ID,
483
+ # APPLE_PRIVATE_KEY, APPLE_KEY_ID, APPLE_TEAM_ID
484
+ #
485
+ # A provider is enabled when its client ID is present
486
+ # (StandardId.social_provider_enabled?(:google)). An enabled provider missing
487
+ # fields its plugin marks required is reported at boot — see
488
+ # c.social.provider_misconfiguration below.
489
+
490
+ # c.social.google_client_id = Rails.application.credentials.dig(:google, :client_id)
491
+ # c.social.google_client_secret = Rails.application.credentials.dig(:google, :client_secret)
492
+ # c.social.apple_client_id = ENV["APPLE_CLIENT_ID"] # web (Services ID) flow
493
+ # c.social.apple_mobile_client_id = ENV["APPLE_MOBILE_CLIENT_ID"] # native id_token flow
494
+ # c.social.apple_private_key = ENV["APPLE_PRIVATE_KEY"] # .p8 PEM, newlines intact
495
+ # c.social.apple_key_id = ENV["APPLE_KEY_ID"]
496
+ # c.social.apple_team_id = ENV["APPLE_TEAM_ID"]
497
+
498
+ # What to do at boot when an enabled provider is missing required fields
499
+ # (e.g. APPLE_CLIENT_ID set without APPLE_PRIVATE_KEY — the sign-in flow
500
+ # would start, then fail at the callback). :warn logs a warning; :raise
501
+ # raises StandardId::ConfigurationError in production and warns elsewhere.
502
+ # Default: :warn
503
+ # c.social.provider_misconfiguration = :raise
504
+
505
+ # Mobile redirect URI allow-list — custom schemes used by native apps.
506
+ # Default: []
507
+ # c.social.allowed_redirect_url_prefixes = ["example-app://"]
508
+
509
+ # OAuth scopes the host app supports for social providers.
510
+ # Default: []
511
+ # c.social.available_scopes = %w[profile email offline_access]
512
+
513
+ # Account linking strategy, for a social login whose (provider, sub) is not
514
+ # linked yet but whose email belongs to an existing account:
515
+ # :strict — link only if that email identifier came from the same
516
+ # provider, predates provider tracking, or the account
517
+ # already has an identifier from this provider (default)
518
+ # :trust_provider — link to the account that holds the email
519
+ # Under both, the provider must report the email as verified, and an email
520
+ # already linked to a different sub from the same provider is refused.
521
+ # Default: :strict
522
+ # c.social.link_strategy = :trust_provider
523
+
524
+ # Attributes written to the Account on social signup.
525
+ # Default: nil
526
+ # c.social.social_account_attributes = ->(social_info:, provider:) {
527
+ # { email: social_info[:email], name: social_info[:name] || social_info[:given_name] }
528
+ # }
529
+
530
+ # ---------------------------------------------------------------------------
531
+ # OIDC logout allow-list
532
+ # ---------------------------------------------------------------------------
533
+
534
+ # Valid post_logout_redirect_uri values accepted by the OIDC RP-initiated
535
+ # logout endpoint.
536
+ # Default: []
537
+ # c.allowed_post_logout_redirect_uris = [
538
+ # "https://app.example.com/logged_out",
539
+ # "https://admin.example.com/signout"
540
+ # ]
541
+
542
+ # ---------------------------------------------------------------------------
543
+ # Rate limits (Rails 8 rate_limit DSL)
544
+ # ---------------------------------------------------------------------------
545
+ # Defaults are conservative; tune per environment.
546
+
547
+ # Login limits. The login action branches password OR passwordless, so on a
548
+ # passwordless app these govern the OTP-SEND limit. Prefer the
549
+ # mechanism-agnostic names (password_login_per_ip/_per_email were removed in 0.43).
550
+ # c.rate_limits.login_per_ip = 20 # per 15 minutes
551
+ # c.rate_limits.login_per_email = 5 # per 15 minutes
552
+ # c.rate_limits.otp_verify_per_ip = 20 # per 15 minutes
553
+ # c.rate_limits.verification_start_per_target = 3 # per 15 minutes
554
+ # c.rate_limits.verification_start_per_ip = 10 # per hour
555
+ # c.rate_limits.api_passwordless_start_per_ip = 10 # per hour
556
+ # c.rate_limits.api_passwordless_start_per_target = 5 # per 15 minutes
557
+ # c.rate_limits.api_token_per_ip = 30 # per 15 minutes
558
+
559
+ # Token introspection (only relevant with c.oauth.introspection_enabled).
560
+ # Throttles by IP so the endpoint can't be used to bulk-classify stolen
561
+ # tokens. Note the limit renders the ordinary {"active": false} / 200 rather
562
+ # than 429 — see the introspection_enabled comment above for why.
563
+ # c.rate_limits.introspection_per_ip = 30 # per 15 minutes
564
+
565
+ # Optional per-audience tightening on top of api_token_per_ip. Only token
566
+ # requests targeting a configured audience count toward its cap (per IP,
567
+ # per 15 minutes); unlisted audiences are governed by the global ceiling.
568
+ # c.rate_limits.api_token_per_audience_per_ip = { "mobile_app" => 10 }
569
+
570
+ # ---------------------------------------------------------------------------
571
+ # Observability
572
+ # ---------------------------------------------------------------------------
573
+
574
+ # Extra fields merged into Sentry user context for authenticated requests.
575
+ # Receives (account, session). Return a Hash; non-callable values ignored.
576
+ # Default: nil
577
+ # c.sentry_context = ->(account, _session) {
578
+ # { segment: account.primary_profile&.type }
579
+ # }
580
+ end
581
+
582
+ # Notes:
583
+ # - All scopes/fields are defined via the schema DSL in
584
+ # lib/standard_id/config/schema.rb
585
+ # - Values are cast to the declared type (string, integer, boolean, ...).
586
+ # - Invalid scopes or fields raise ArgumentError with a helpful message.
@@ -0,0 +1,26 @@
1
+ module StandardId
2
+ # Whether this deploy is production, for every check the gem makes.
3
+ #
4
+ # The estate convention (shared by standard_audit, standard_health and
5
+ # standard_singpass) is the APP_ENVIRONMENT variable, falling back to
6
+ # Rails.env: a physically-staging box runs RAILS_ENV=production with
7
+ # APP_ENVIRONMENT=staging, so Rails.env alone would call staging production.
8
+ #
9
+ # It fails closed. Only the known non-production names count as
10
+ # non-production; any other value (a typo such as "prod", or "production")
11
+ # is production, so a misconfigured variable can never switch off a
12
+ # production-only guard.
13
+ module AppEnvironment
14
+ NON_PRODUCTION = %w[staging preview development test].freeze
15
+
16
+ module_function
17
+
18
+ # @return [Boolean]
19
+ def production?
20
+ app_env = ENV["APP_ENVIRONMENT"].to_s.strip
21
+ return !NON_PRODUCTION.include?(app_env) unless app_env.empty?
22
+
23
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
24
+ end
25
+ end
26
+ end
@@ -9,6 +9,11 @@ StandardId::ConfigSchema.define do
9
9
  field :cache_store, type: :any, default: nil
10
10
  field :logger, type: :any, default: nil
11
11
  field :web_layout, type: :string, default: nil
12
+ # The class StandardId's mailers inherit from, e.g. "ApplicationMailer" so
13
+ # they pick up the host's layout, helpers, default_url_options and locale
14
+ # handling. Read once, when the gem's mailers load, so set it in an
15
+ # initializer.
16
+ field :mailer_parent_class, type: :string, default: "ActionMailer::Base"
12
17
  # Removed in 0.43 (deprecated since 0.1.7): assigning either raises
13
18
  # StandardId::ConfigurationError with this hint.
14
19
  removed :passwordless_email_sender,
@@ -213,19 +218,19 @@ StandardId::ConfigSchema.define do
213
218
  # for *any* realm. Use a long, non-guessable value and unset it
214
219
  # outside test environments.
215
220
  #
216
- # "Production" is decided by production_env_detector below (default
217
- # Rails.env.production?), so host apps that distinguish a physical deploy
218
- # environment from RAILS_ENV can still gate this correctly.
221
+ # "Production" is decided by production_env_detector below, defaulting to
222
+ # StandardId::AppEnvironment: APP_ENVIRONMENT when set (only staging,
223
+ # preview, development and test are non-production; anything else is
224
+ # production), else Rails.env.production?.
219
225
  field :bypass_code, type: :string, default: nil
220
226
 
221
227
  # Optional callable deciding whether the current deploy is "production"
222
228
  # for the purpose of the bypass-code guard. Takes no args, returns a
223
- # boolean. When nil (default), falls back to Rails.env.production? — so
224
- # existing consumers are unchanged. Host apps that distinguish a physical
225
- # deploy environment from RAILS_ENV (e.g. APP_ENVIRONMENT, which stays
226
- # RAILS_ENV=production on a physically-staging box) can supply
227
- # `-> { AppEnv.production? }` to allow a bypass code on staging while it
228
- # stays refused on real production.
229
+ # boolean. When nil (default), StandardId::AppEnvironment decides, which
230
+ # already reads APP_ENVIRONMENT (a physically-staging box with
231
+ # RAILS_ENV=production and APP_ENVIRONMENT=staging is not production), so
232
+ # `-> { AppEnv.production? }` is no longer needed. Set it only for a
233
+ # different discriminator.
229
234
  field :production_env_detector, type: :any, default: nil
230
235
 
231
236
  # Custom username validator for passwordless flows.
@@ -166,7 +166,7 @@ module StandardId
166
166
  # inclusion. Extracted to a module method so specs can exercise it
167
167
  # directly without booting a second Rails app.
168
168
  def self.warn_if_allowed_audiences_empty_in_production!
169
- return unless Rails.env.production?
169
+ return unless StandardId::AppEnvironment.production?
170
170
  return if StandardId.config.oauth.allowed_audiences.present?
171
171
 
172
172
  Rails.logger.warn(