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