standard_id 0.44.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +167 -3
  4. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +12 -19
  5. data/app/controllers/concerns/standard_id/social_authentication.rb +267 -13
  6. data/app/controllers/concerns/standard_id/web/social_login_params.rb +6 -2
  7. data/app/controllers/concerns/standard_id/web_authentication.rb +6 -1
  8. data/app/controllers/standard_id/api/authorization_controller.rb +11 -4
  9. data/app/controllers/standard_id/api/base_controller.rb +16 -0
  10. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +72 -16
  11. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +69 -6
  12. data/app/controllers/standard_id/web/consent_controller.rb +5 -1
  13. data/app/controllers/standard_id/web/login_controller.rb +8 -0
  14. data/app/controllers/standard_id/web/login_verify_controller.rb +6 -1
  15. data/app/controllers/standard_id/web/signup_controller.rb +6 -1
  16. data/db/migrate/20261004000000_add_auth_method_to_standard_id_refresh_tokens.rb +17 -0
  17. data/lib/generators/standard_id/install/templates/standard_id.rb +13 -0
  18. data/lib/standard_id/account_cleanup.rb +147 -0
  19. data/lib/standard_id/api/token_manager.rb +18 -2
  20. data/lib/standard_id/auth_lineage.rb +75 -0
  21. data/lib/standard_id/config/callable_validator.rb +5 -0
  22. data/lib/standard_id/config/schema.rb +14 -0
  23. data/lib/standard_id/errors.rb +55 -0
  24. data/lib/standard_id/events/definitions.rb +6 -1
  25. data/lib/standard_id/events/subscribers/logging_subscriber.rb +1 -0
  26. data/lib/standard_id/login_method_policy.rb +108 -0
  27. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +2 -1
  28. data/lib/standard_id/oauth/authorization_code_flow.rb +6 -0
  29. data/lib/standard_id/oauth/authorization_flow.rb +6 -2
  30. data/lib/standard_id/oauth/password_flow.rb +13 -2
  31. data/lib/standard_id/oauth/passwordless_otp_flow.rb +4 -0
  32. data/lib/standard_id/oauth/refresh_token_flow.rb +32 -0
  33. data/lib/standard_id/oauth/social_flow.rb +4 -0
  34. data/lib/standard_id/oauth/subflows/social_login_grant.rb +6 -0
  35. data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +6 -1
  36. data/lib/standard_id/oauth/token_grant_flow.rb +54 -2
  37. data/lib/standard_id/providers/base.rb +87 -2
  38. data/lib/standard_id/version.rb +1 -1
  39. data/lib/standard_id/web/session_manager.rb +50 -3
  40. data/lib/standard_id/web/token_manager.rb +8 -3
  41. data/lib/standard_id.rb +3 -0
  42. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea9ca4b9484333d3ed979bc9479b3ddcbde475a3d4f63e9903a0a4b437d925e7
4
- data.tar.gz: 99b5c5993640de8c2634fbdd96fcacb95806a35c130d5b268d7daf51382c5275
3
+ metadata.gz: 2a825be521399d2fbb91a820cbc0da601d24865a12fc549d1a8697ea1d6c97cb
4
+ data.tar.gz: da555da47e6a9ae99f1c888e5ef93c2fc4a02d21b11993300c67d02df6ef5d01
5
5
  SHA512:
6
- metadata.gz: a17095049de6adf0c32413fad6322a58b2f50b77c5db052043a74f082e40e6374518d22dad2518eeb4a01c15921865af993b51bae46447a8ee0135e968e6ec14
7
- data.tar.gz: 2262c87410fd09684f885818aca1f31de00fe6f12daad4db0c6706521acf4d852ee2bdc623e15cc83da1aea9a7fbb7001eaab0129331ff93904056055cef4e9d
6
+ metadata.gz: f54bf303a4cc32095b3117df9636b9f3e04ebe4483de5b79f513a0089b44d45a36a3ff7dbaf54d0858a4c29c657e11452f9e7b4ffe89e7418fbf55598ecf75a7
7
+ data.tar.gz: 157b2b4ffdca2f4a22eaf78207ba28579ef71c59693d6f8587159be19bd36d9540c008e9ede3bbbcc9f313f10d5ef762b9553e898428ffe4f37f7f513c83c2ef
data/CHANGELOG.md CHANGED
@@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.46.0] - 2026-10-05
11
+
12
+ ### Added
13
+
14
+ - **Callback `iss` for providers (RFC 9207).** The web and API social callbacks pass the request's `iss` parameter to `get_user_info` as `callback_iss:` when it is a String, so a provider can refuse an authorization-server mix-up without hooking the callback controller itself. On the API callback, `iss` is no longer forwarded to `SOCIAL_AUTH_COMPLETED` subscribers as `original_request_params`, like the other OAuth-flow params.
15
+ - **Core-managed PKCE: `Providers::Base.supports_pkce?`** (default `false`). For a provider that returns `true`, `/login?connection=<provider>` generates a fresh verifier per sign-in, stores it with the state and nonce in the encrypted pending-requests cookie, and passes `code_challenge:` / `code_challenge_method: "S256"` to `authorization_url`. The web callback passes the stored verifier to `get_user_info` as `code_verifier:`, and refuses a callback whose stored request has none. The API callback never passes a verifier (it has no server-held flow state; a client-supplied `code_verifier` is not forwarded), and the API social login grant refuses a PKCE provider. `build_authorization_url` emits `code_challenge` / `code_challenge_method` when `options` carries them; `Providers::Base.pkce_s256_challenge(verifier)` computes the S256 challenge. Providers that do not opt in see no change: both new kwargs reach them only through `**options`, and nil values are not passed. See the README, "Callback `iss` and core-managed PKCE".
16
+
17
+ ### Fixed
18
+
19
+ - **Social-link races are classified correctly under MySQL/InnoDB `REPEATABLE READ`.** When a concurrent login committed a rival `(provider, sub)` or `(identifier, provider)` row after this login's lookup, the re-read that decides who won ran inside the link transaction as a plain `SELECT`. Under `REPEATABLE READ` that read the transaction's earlier snapshot, missed the rival, and re-raised the `RecordNotUnique`: a 500 with no `SOCIAL_LINK_BLOCKED` instead of a retryable `invalid_grant` (or adopting a same-account rival). `SocialAuthentication#classify_social_link_race!` now uses locking reads (`SELECT ... FOR UPDATE`), which always see the latest committed row. PostgreSQL (`READ COMMITTED`) already saw the rival and behaves as before; on SQLite the lock is a no-op.
20
+ - **A refused request's new account is reclaimed when the login it was kept for fails too.** Since 0.45.0, `AccountCleanup.destroy_newly_created!` keeps a refused request's new account when a concurrent login (B) has already signed in to it. If B then failed as well, B revoked its own session or refresh token but, not having created the account, removed nothing, so the account and its identifier were left behind with no way in and blocked a later signup for that address. The kept account now remembers the session and refresh-token ids it was kept for (in `StandardId.cache_store`, for one hour), and the social callbacks and `handle_authentication_denied` call the new **`AccountCleanup.reclaim_for_failed_adopter!`** with what the failing login issued. The account is removed, under the same row lock, only when one of those credentials was among the remembered ones and nothing else is signed in to it; if another login still is, the account is kept for that one instead. A login that succeeded, or any other later sign-in, never triggers the removal. This needs a cache store shared by all processes (e.g. Solid Cache or Redis); with a per-process store or `:null_store` nothing is remembered and the account is kept, as in 0.45.0.
21
+ - **`SOCIAL_ACCOUNT_LINKED` is no longer published for a link that was never recorded.** When `SocialAuthentication#record_social_identity!` found a `(provider, sub)` row that was then removed (for example by a concurrent account cleanup) before `classify_social_link_race!` re-read it, it returned without inserting: the provider backfill committed and `SOCIAL_ACCOUNT_LINKED` was published, yet no `(provider, sub)` row existed, so later logins were not matched by subject. The `sub` is free at that point, so the login now goes on to insert the row (any rival that commits meanwhile is classified as before), and the event is published only for a row that was inserted or adopted. Under MySQL/InnoDB `REPEATABLE READ` the uniqueness validations of that insert read the transaction's snapshot, which still holds the removed row, and raised `RecordInvalid`; when the locking re-read finds no rival, the insert is now retried once without validations (every other validation passed, and unique indexes back both uniqueness rules).
22
+
23
+ ## [0.45.0] - 2026-10-04
24
+
25
+ Two opt-in hooks that the org-IdP provider plugin (`standard_id-void_which_binds`) needs. With neither configured, behaviour is unchanged.
26
+
27
+ ### Added
28
+
29
+ - **`Providers::Base.trusted_for_linking?`** (default `false` for every provider, including Google and Apple). When a provider returns exactly `true`, the `:strict` link_strategy may link its login to an existing account that was created through **another** provider, when the emails match. Every 0.44 (L1-01) guard still applies, and one more is added for this path:
30
+ - the provider must report `email_verified` as `true` / `"true"` (`:email_unverified` otherwise);
31
+ - the existing email identifier must itself be verified, closing pre-account hijacking (`:link_required` otherwise);
32
+ - the identifier must not be linked to a different `sub` from that provider (`:subject_mismatch`);
33
+ - a stored `(provider, sub)` match still wins over any email match.
34
+
35
+ Only an organisation's own IdP, whose email claims the organisation provisions and verifies (e.g. moneta's broker-local directory), should return `true`. A public IdP must not: anyone who controls an address there could take over the matching account here. See the README, "Trusted linking for the organisation's own IdP".
36
+ - **`c.login_method_policy`**: a callable consulted in every flow that establishes a new authentication, after the credential is proven and before any session, token or cookie is created. It receives any subset of `account:`, `auth_method:` (`:password`, `:passwordless`, `:social`, `:remember_me`, `:unspecified`), `provider:`, `request:` and `flow:`. Return truthy to allow, `false`/`nil` to refuse, or raise `StandardId::LoginMethodDenied` with a message. Default `nil` (allow all). Covered flows:
37
+ - web: `/login` password, `/signup`, `/login_verify` passwordless, the social callback, remember-me re-authentication, and host calls to `session_manager.sign_in_account`;
38
+ - API: the `password`, `passwordless_otp` and `refresh_token` token grants, the `/api/oauth/callback/:provider` social callback, and host calls to `Api::TokenManager#create_device_session` / `#create_service_session`.
39
+
40
+ The `refresh_token` grant is checked with the method of the **original** sign-in (flow `:oauth_refresh_token`; see below). `authorization_code`, the implicit flow and `client_credentials` derive from an earlier authentication (or have no account) and are not gated. A refusal redirects to `/login` with the message as the alert on the web, returns `403 access_denied` on the API, signs out silently for remember-me, and removes an account that the same request had just created. A refused `refresh_token` grant answers `400 invalid_grant` ("Refresh token is no longer valid", the same as any dead refresh token, so the presenter learns nothing) and revokes the token family. The table and the full contract are in the README under "Restricting sign-in methods".
41
+ - **Authentication lineage** (`StandardId::AuthLineage`). The sign-in method and provider are recorded and carried along: web sign-in → `BrowserSession#metadata` (`auth_method`, `auth_provider`) → the authorization code minted from it (`metadata`) → the refresh token → every rotated successor. Migration **`20261004000000_add_auth_method_to_standard_id_refresh_tokens`** adds the two nullable `auth_method` / `auth_provider` string columns to `standard_id_refresh_tokens`. There is no default, index or backfill, so on PostgreSQL the change is metadata-only and safe under StrongMigrations. Until the migration has run the gem does not write the columns. `Web::TokenManager#create_browser_session` and `Oauth::AuthorizationFlow.new` take an optional `auth_lineage:`.
42
+ - **`StandardId::LoginMethodDenied`** (subclass of `AuthenticationDenied`, so existing web handling covers it; `oauth_error_code` `:access_denied`, `http_status` `:forbidden`) and the event **`authentication.method.denied`** (`AUTHENTICATION_METHOD_DENIED`; payload `account`, `auth_method`, `provider`, `flow`, `error_message`). The event is in `SECURITY_EVENTS`, so audit trails that record `standard_id.authentication.*` pick it up.
43
+ - `Web::SessionManager#sign_in_account` takes optional `auth_method:`, `provider:` and `flow:`. `Api::TokenManager#create_device_session` and `#create_service_session` take optional `auth_method:` and `provider:`. Host code that signs accounts in itself should pass `auth_method:`; without it a configured policy sees `:unspecified`.
44
+ - `StandardId::AccountCleanup.destroy_newly_created!`, extracted from `LifecycleHooks#destroy_newly_created_account` so the API grants can share it.
45
+
46
+ ### Changed
47
+
48
+ - **A refused social login no longer leaves its provider link behind.** On the web and API callbacks, the `(provider, sub)` row and any provider backfilled onto a `NULL`-provider email identifier are written only once the login is accepted, in one transaction. When the login-method policy, `before_sign_in`, a scope/profile check or anything later refuses the sign-in, nothing is written. On the web the write shares a transaction with the final redirect, so a redirect that raises (e.g. an `after_sign_in` URL on a host that is not allowed) rolls the link back too. Links that existed beforehand are kept.
49
+ - **`social.account.linked` is published only after the link is written.** It was published as soon as an email match was found, so a refused login left an audit record of a link that never existed. A login that matches an existing link by `sub` still publishes it at match time. Because the link has already committed when it is published, a subscriber that raises no longer fails the login: the error is logged and reported to `Rails.error` (`handled: true`). Put guards on events that fire before anything is written.
50
+ - **A refused request's new account is removed only if nothing else is using it.** `AccountCleanup.destroy_newly_created!` now locks the account row and keeps the account when it has an active session or refresh token (the refusing request revokes its own first); it returns `true` when it removed the account. The social callbacks take the same lock before writing a link and fail when the account is gone. So a concurrent login for the same email either signs in first and the account is kept, or fails with a retryable error (web: `/login` with an alert; API: `400 invalid_grant`) and a retry creates a fresh account. It never signs in to, or links to, an account that is being removed. The API callback now revokes the refresh token (and its session) the grant persisted even when the grant raised after writing them (`TokenGrantFlow#issued_refresh_token`).
51
+ - When two logins race to create the same `(provider, sub)` row and the winner belongs to a different account, the loser now fails with `invalid_grant` instead of signing in to one account while the subject is linked to another. The link is now written with an explicit find-then-insert (no longer `find_or_create_by!`), and every way the race surfaces is classified: a rival row found by the SELECT, a uniqueness-validation `RecordInvalid` (rival committed after the SELECT), or a unique-index `RecordNotUnique` (rival committed after validation). A rival for the same account is adopted. Any other `RecordInvalid` still propagates. The refusal raises `StandardId::SocialLinkConflictError` (a subclass of `InvalidGrantError`, so clients still see a retryable `invalid_grant`) and publishes `SOCIAL_LINK_BLOCKED` with the new reason **`:subject_conflict`**, after the link transaction has rolled back. The web callback no longer reports it as `SOCIAL_AUTH_FAILED`, which is for infrastructure failures. When instead the `(identifier, provider)` index wins (a concurrent login linked the same identifier to the provider under another sub), the reason is `:subject_mismatch`.
52
+
53
+ ### Upgrade notes
54
+
55
+ 1. Run `bin/rails standard_id:install:migrations && bin/rails db:migrate` (one new migration: two nullable columns on `standard_id_refresh_tokens`).
56
+ 2. Nothing changes until you set `c.login_method_policy` or a provider plugin returns `trusted_for_linking? == true`.
57
+ 3. **Before enabling a restrictive policy (e.g. `standard_id-void_which_binds`' `require_for_staff`), update host code that signs accounts in itself.** Calls to `session_manager.sign_in_account(account)` and `Api::TokenManager#create_device_session` / `#create_service_session` without `auth_method:` reach the policy as `:unspecified`. A staff policy treats that as "not via the org IdP" and refuses: the call raises `StandardId::LoginMethodDenied`, which is an `AuthenticationDenied`. Pass the real method (e.g. `auth_method: :password`, or `:social` with `provider:`) and rescue `StandardId::AuthenticationDenied`. Known call sites without it: nutripod-web (`ApplicationController`, `Inertia::ApplicationController`, `InviteAcceptancesController`, `Auth::SessionActions`) and fundbright-web (`OrganisationInvitationsController`, `PlatformInvitationsController`, `Widget::OffersController`, `Auth::SessionActions`).
58
+ 4. **Expect existing OAuth clients to re-authenticate once a restrictive policy is on.** Refresh tokens minted before 0.45 (or before the migration) carry no method and are checked as `:unspecified`, so such a policy refuses them (`invalid_grant`) and revokes their family. This fails closed by design. Existing browser sessions are not re-checked; revoke them if they must end.
59
+
60
+ ### Fixed
61
+
62
+ - **Remember-me restore raised `ArgumentError` (500).** `SessionManager#load_session_from_remember_token` passed `remember_me: true` to `Web::TokenManager#create_browser_session`, which has no such keyword, so every request carrying a valid remember-me cookie and no live session failed. The restored session now records its lineage as `auth_method: "remember_me"`, the method the login-method policy checked, so refresh tokens derived from it are re-checked as `:remember_me`.
63
+ - **The OAuth `password` grant failed for every existing user.** `PasswordFlow` preloaded `credential: :account`, but `Credential` reaches the account through its identifier and has no such association. Any request that found a credential raised `ActiveRecord::AssociationNotFoundError` (500). A wrong password for an existing login also returned 500 (`false.account`) instead of `400 invalid_grant`. Both have existed since the grant was added. The grant had no request-level coverage; `spec/requests/standard_id/api/oauth/password_grant_spec.rb` now covers it.
64
+
10
65
  ## [0.44.0] - 2026-10-02
11
66
 
12
67
  **Security fix (L1-01): social login could take over an existing account by email.** Behaviour change and a new migration; see **Upgrade notes**.
data/README.md CHANGED
@@ -673,6 +673,7 @@ Every StandardId event automatically carries tracing metadata (`event_id`, `time
673
673
  | | `authentication.attempt.failed` | `account_lookup`, `auth_method`, `error_code`, `error_message` | After authentication fails |
674
674
  | | `authentication.password.failed` | `account_lookup`, `error_code`, `error_message` | After password verification fails |
675
675
  | | `authentication.otp.failed` | `identifier`, `channel`, `error_code`, `error_message` | After OTP verification fails |
676
+ | | `authentication.method.denied` | `account`, `auth_method`, `provider`, `flow`, `error_message` | `config.login_method_policy` refused the method, after the credential was proven and before any session or token was created (0.45+) |
676
677
  | Session | `session.creating` | `account`, `session_type`, `ip_address`, `user_agent` | Before a session record is created |
677
678
  | | `session.created` | `session`, `account`, `session_type`, `token_issued`, `ip_address`, `user_agent` | After session persistence completes |
678
679
  | | `session.validating` | `session` | Before validating an existing session |
@@ -708,7 +709,7 @@ Every StandardId event automatically carries tracing metadata (`event_id`, `time
708
709
  | | `social.auth.callback_received` | `provider`, `code`, `state` | After the provider redirects back |
709
710
  | | `social.user_info.fetched` | `provider`, `social_info`, `email` | After fetching user info from the provider |
710
711
  | | `social.account.created` | `account`, `provider`, `social_info` | When a social login creates a new account |
711
- | | `social.account.linked` | `account`, `provider`, `identifier` | When a social identity links to an existing account |
712
+ | | `social.account.linked` | `account`, `provider`, `identifier` | When a social login links to an existing account by email (published once the link is written, never for a refused login), or matches an existing link by `sub` |
712
713
  | | `social.auth.completed` | `account`, `provider`, `tokens` | After social login completes |
713
714
  | | `social.auth.failed` | `provider`, `error`, `error_class`, `account` | When social login fails due to an infrastructure error (HTTP/DNS/SSL/timeout) |
714
715
  | Credential | `credential.password.created` | `credential`, `account` | After a password credential is created |
@@ -1174,7 +1175,10 @@ redirect_to "/api/authorize?" + {
1174
1175
  existing account, the login links to it only when all of these hold, and
1175
1176
  raises `StandardId::SocialLinkError` (after emitting `SOCIAL_LINK_BLOCKED`)
1176
1177
  when one does not. The error's `reason` says which:
1177
- - `link_strategy` allows it (`:link_required` under `:strict`);
1178
+ - `link_strategy` allows it (`:link_required` under `:strict`). Under
1179
+ `:strict`, a provider whose `trusted_for_linking?` returns `true` may
1180
+ link to an account created through another provider, but only when the
1181
+ existing email identifier is itself verified (see below);
1178
1182
  - the identifier is not already linked to a **different** `sub` from the
1179
1183
  same provider (`:subject_mismatch`);
1180
1184
  - the provider reports the email as verified: `email_verified` is `true`
@@ -1182,6 +1186,10 @@ redirect_to "/api/authorize?" + {
1182
1186
  `:trust_provider` too.
1183
1187
 
1184
1188
  A successful link stores the `sub`, so the next login matches on step 1.
1189
+ If a concurrent login commits the same `sub` for a **different** account
1190
+ while this one writes its link, the login fails with a retryable
1191
+ `invalid_grant` (`StandardId::SocialLinkConflictError`) and
1192
+ `SOCIAL_LINK_BLOCKED` is published with reason `:subject_conflict`.
1185
1193
  3. **New account.** Otherwise a new account is created, as before, and the
1186
1194
  `sub` is stored.
1187
1195
 
@@ -1189,6 +1197,118 @@ Providers must return the OIDC `sub` and `email_verified` claims in
1189
1197
  `user_info`. A provider that returns no `sub` still works, but is matched on
1190
1198
  email (with the verified-email requirement) every time.
1191
1199
 
1200
+ #### Trusted linking for the organisation's own IdP (0.45+)
1201
+
1202
+ `:strict` refuses to link, say, an org-SSO login to an existing account that
1203
+ was first created by a Google login, even when the addresses match. A provider
1204
+ plugin can lift that one refusal by returning `true` from
1205
+ `Providers::Base.trusted_for_linking?` (default `false` for every provider,
1206
+ including Google and Apple, so nothing changes unless a plugin opts in).
1207
+
1208
+ **Only the organisation's own identity provider should ever return `true`**:
1209
+ one whose email claims the organisation itself provisions and verifies, such as
1210
+ a broker-local directory (moneta, via `standard_id-void_which_binds`). A public
1211
+ IdP lets anyone register an address there; trusting it would let whoever holds
1212
+ an address *at that IdP* take over the account holding the same address here.
1213
+
1214
+ Trust relaxes only the `:strict` cross-provider check. The link is still
1215
+ refused unless all of these hold:
1216
+
1217
+ - the provider reports `email_verified` as `true` / `"true"`
1218
+ (`:email_unverified` otherwise);
1219
+ - the existing email identifier is verified (`verified_at` set). This closes
1220
+ pre-account hijacking: someone who registered an account for an address they
1221
+ never proved must not have it handed to the address's real owner (they would
1222
+ keep their own way in). Refused as `:link_required`;
1223
+ - the identifier is not linked to a different `sub` from the same provider
1224
+ (`:subject_mismatch`);
1225
+ - `trusted_for_linking?` returns exactly `true` (a truthy string does not count).
1226
+
1227
+ A stored `(provider, sub)` match still wins over any email match.
1228
+
1229
+ ### Restricting sign-in methods (`login_method_policy`, 0.45+)
1230
+
1231
+ `c.login_method_policy` decides, per account, which authentication methods may
1232
+ sign it in, for example "staff must sign in with the org IdP". The default
1233
+ (`nil`) allows everything.
1234
+
1235
+ ```ruby
1236
+ StandardId.configure do |c|
1237
+ # Any subset of: account:, auth_method:, provider:, request:, flow:
1238
+ c.login_method_policy = ->(account:, auth_method:, provider:) {
1239
+ next true unless account.staff?
1240
+ next true if auth_method == :social && provider == "void_which_binds"
1241
+
1242
+ raise StandardId::LoginMethodDenied, "Staff must sign in with Void Which Binds"
1243
+ }
1244
+ end
1245
+ ```
1246
+
1247
+ Return a truthy value to allow, `false`/`nil` to refuse with a generic
1248
+ message, or raise `StandardId::LoginMethodDenied` with your own message. Any
1249
+ other exception propagates, so a broken policy fails closed.
1250
+
1251
+ The policy is consulted in every flow that establishes a new authentication,
1252
+ **after** the credential is proven (password checked, code verified, provider
1253
+ token verified) and **before** any session, token or cookie is created. Because
1254
+ it runs only after the credential is proven, its message reaches only someone
1255
+ who already controls the credential; a wrong password still gets the flow's
1256
+ usual "invalid" answer and never reaches the policy.
1257
+
1258
+ | `flow` | `auth_method` | Refusal |
1259
+ |--------|---------------|---------|
1260
+ | `:web_password` (`/login`) | `:password` | redirect to `/login`, message as flash alert |
1261
+ | `:web_signup` (`/signup`) | `:password` | same; the just-created account is removed |
1262
+ | `:web_passwordless` (`/login_verify`) | `:passwordless` | same; a just-registered account is removed |
1263
+ | `:web_social` (`/auth/callback/:provider`) | `:social` + `provider` | same; a just-created account is removed |
1264
+ | `:web_remember_me` (remember-me cookie) | `:remember_me` | signed out silently, remember cookie cleared |
1265
+ | `:web_session` (`session_manager.sign_in_account` from host code) | what you pass, else `:unspecified` | raises `LoginMethodDenied` (an `AuthenticationDenied`) |
1266
+ | `:oauth_password_grant` | `:password` | `403 {"error":"access_denied"}` |
1267
+ | `:oauth_passwordless_otp_grant` | `:passwordless` | `403 access_denied`; a just-registered account is removed |
1268
+ | `:oauth_social_callback` (`/api/oauth/callback/:provider`) | `:social` + `provider` | `403 access_denied`; a just-created account is removed |
1269
+ | `:oauth_refresh_token` | the **original** sign-in's method (and provider), else `:unspecified` | `400 invalid_grant` ("Refresh token is no longer valid"), and the token family is revoked |
1270
+ | `:api_device_session` / `:api_service_session` (`Api::TokenManager#create_*_session` from host code) | what you pass, else `:unspecified` | raises `LoginMethodDenied` |
1271
+
1272
+ **Refresh tokens remember how their authentication was made.** The method
1273
+ (and provider) is recorded at sign-in and carried along: web sign-in → the
1274
+ browser session's `metadata` → the authorization code minted from it at
1275
+ `/api/authorize` → the refresh token (`auth_method` / `auth_provider` columns,
1276
+ migration `20261004000000`) → every rotated successor. The `refresh_token`
1277
+ grant re-checks the policy with that original method, so tightening the policy
1278
+ takes effect at each OAuth client's next refresh. A refused refresh is answered
1279
+ like any dead refresh token (`invalid_grant`, nothing about why, since whoever
1280
+ presents a refresh token may not be its holder) and revokes the family; the
1281
+ reason is in the `authentication.method.denied` event. Refresh tokens minted
1282
+ before 0.45 (or before the migration ran) have no recorded method and are
1283
+ checked as **`:unspecified`**: a policy that requires a particular method will
1284
+ refuse them, failing closed, and those clients must sign in again.
1285
+
1286
+ Not gated: the `authorization_code` grant and the implicit flow (minted within
1287
+ minutes from a browser session that passed the policy when it was created) and
1288
+ `client_credentials` (no account). Browser sessions that already exist are not
1289
+ re-checked when the policy changes; revoke them if they must end.
1290
+
1291
+ A refused social login also leaves no trace of the link: the `(provider, sub)`
1292
+ row and any provider backfilled onto the email identifier are written only once
1293
+ the login is accepted (on the web, together with the final redirect), so a
1294
+ refusal by the policy, `before_sign_in`, a scope check or a failing redirect
1295
+ writes nothing and publishes no `social.account.linked`.
1296
+
1297
+ A just-created account is removed only if no other sign-in is using it. When a
1298
+ concurrent login for the same email has already signed in to it, it is kept;
1299
+ when the removal goes first, that login fails with a retryable error (web:
1300
+ redirect to `/login`; API: `400 invalid_grant`), never signs in to the removed
1301
+ account, and a retry creates a fresh one.
1302
+
1303
+ Every refusal publishes `authentication.method.denied`.
1304
+
1305
+ **Host code that signs accounts in itself** (`session_manager.sign_in_account`,
1306
+ `Api::TokenManager#create_device_session`) should pass the method, e.g.
1307
+ `session_manager.sign_in_account(account, auth_method: :password)`, and rescue
1308
+ `StandardId::AuthenticationDenied` (which `LoginMethodDenied` subclasses) as the
1309
+ engine's controllers do. Without `auth_method:` the policy sees
1310
+ `:unspecified`, which a restrictive policy will refuse.
1311
+
1192
1312
  ### Passwordless Authentication
1193
1313
 
1194
1314
  ```ruby
@@ -1406,22 +1526,66 @@ end
1406
1526
  | `flow_for(params)` | `:web` only for `flow=web` on providers that `supports_mobile_callback?`, else `:mobile` | Flow for the API callback |
1407
1527
  | `skip_csrf?` | `false` | `true` for POST (form_post) callbacks |
1408
1528
  | `supports_mobile_callback?` | `false` | Enables the server-side redirect back to a native app |
1529
+ | `trusted_for_linking?` | `false` | `true` lets `:strict` link to an account created via another provider (verified emails only). Org-owned IdPs only; see "Trusted linking" (0.45+) |
1530
+ | `supports_pkce?` | `false` | `true` makes core generate, store and hand back a PKCE (S256) verifier for the web flow; see "Callback `iss` and core-managed PKCE" (0.46+) |
1409
1531
 
1410
1532
  **Protected helpers** for use inside those methods — signatures are stable:
1411
1533
 
1412
1534
  | Helper | Does |
1413
1535
  |--------|------|
1414
1536
  | `build_response(user_info, tokens:)` | The standard `get_user_info` return value |
1415
- | `build_authorization_url(endpoint:, client_id:, redirect_uri:, state:, options: {}, defaults: {}, response_type: "code")` | `client_id`, `redirect_uri`, `response_type`, `state`, then each `supported_authorization_params` entry from `options` or `defaults`; nils dropped |
1537
+ | `build_authorization_url(endpoint:, client_id:, redirect_uri:, state:, options: {}, defaults: {}, response_type: "code")` | `client_id`, `redirect_uri`, `response_type`, `state`, then each `supported_authorization_params` entry from `options` or `defaults`, then `code_challenge` / `code_challenge_method` from `options`; nils dropped |
1416
1538
  | `extract_tokens(parsed_token)` | `{ access_token:, refresh_token:, id_token: }` from a token response, nils dropped |
1417
1539
  | `verify_nonce!(expected:, actual:)` | Constant-time nonce check; no-op when `expected` is blank. Raises `InvalidRequestError` without echoing either value |
1418
1540
  | `rescue_to_oauth_error(message_prefix = nil) { ... }` | Lets `StandardId::OAuthError` through; wraps anything else in one, keeping `cause` |
1541
+ | `pkce_s256_challenge(code_verifier)` (public) | `BASE64URL(SHA256(verifier))`, unpadded (RFC 7636 S256) |
1419
1542
 
1420
1543
  A plugin using `env:`, `required:` or these helpers should depend on
1421
1544
  `standard_id >= 0.42`. `Providers::Base.setup` is no longer called (removed
1422
1545
  from the base class in 0.42; the call-with-a-warning shim went in 0.43) — do
1423
1546
  one-off initialization in your own Railtie instead.
1424
1547
 
1548
+ #### Callback `iss` and core-managed PKCE (0.46+)
1549
+
1550
+ Core hands two more values to `get_user_info`, so a provider does not have to
1551
+ capture or derive them itself. Both arrive through `**options`; a provider
1552
+ that does not name them ignores them, and a nil value is never passed.
1553
+
1554
+ - **`callback_iss:`** — the callback's `iss` parameter (RFC 9207), when the
1555
+ redirect carried one as a String. Compare it with the issuer you expect
1556
+ before exchanging the code (authorization-server mix-up defence). It is
1557
+ passed on both the web callback and `/api/oauth/callback/:provider` (where
1558
+ it is whatever the client relayed); refuse a missing one if your IdP always
1559
+ sends it.
1560
+ - **`code_verifier:`** — for a provider whose `supports_pkce?` returns `true`.
1561
+ On every `/login?connection=<provider>` core generates a fresh verifier,
1562
+ stores it with the state and nonce in the encrypted pending-requests cookie
1563
+ (it never appears in a URL), and passes `code_challenge:` and
1564
+ `code_challenge_method: "S256"` to `authorization_url`
1565
+ (`build_authorization_url` emits both). At the web callback the stored
1566
+ verifier comes back as `code_verifier:`; send it in the token request. A
1567
+ web callback whose stored request has no verifier (a state issued for
1568
+ another provider, or one started before the provider opted in) is refused
1569
+ before the provider is called.
1570
+
1571
+ ```ruby
1572
+ def self.supports_pkce? = true
1573
+
1574
+ def self.get_user_info(code: nil, redirect_uri: nil, nonce: nil, callback_iss: nil, code_verifier: nil, **_options)
1575
+ raise StandardId::InvalidRequestError, "issuer mismatch" unless callback_iss == EXPECTED_ISSUER
1576
+ raise StandardId::InvalidRequestError, "web sign-in only" if code_verifier.blank?
1577
+
1578
+ # POST code, redirect_uri and code_verifier to the token endpoint ...
1579
+ end
1580
+ ```
1581
+
1582
+ The API paths keep no server-side flow state, so they never pass a
1583
+ `code_verifier:` (a client-supplied `code_verifier` param is not forwarded),
1584
+ and the API social login grant (`/authorize?connection=...`) refuses to start
1585
+ a sign-in for a provider that `supports_pkce?` rather than send it without a
1586
+ challenge. A PKCE provider is therefore web-only unless it handles a native
1587
+ flow itself.
1588
+
1425
1589
  **Testing a plugin, or an app that uses one:**
1426
1590
 
1427
1591
  ```ruby
@@ -191,8 +191,16 @@ module StandardId
191
191
  # @param account [Object, nil] the account to clean up if newly created
192
192
  # @param newly_created [Boolean] whether the account was created during this request
193
193
  def handle_authentication_denied(error, account: nil, newly_created: false)
194
+ # Only a session THIS request created can have kept a refused new
195
+ # account alive (see AccountCleanup.reclaim_for_failed_adopter!), never
196
+ # one the browser already had.
197
+ created_session = session_manager.created_session if session_manager.respond_to?(:created_session)
194
198
  session_manager.revoke_current_session! if session_manager.current_session.present?
195
- destroy_newly_created_account(account) if newly_created
199
+ if newly_created
200
+ destroy_newly_created_account(account)
201
+ elsif account && created_session
202
+ StandardId::AccountCleanup.reclaim_for_failed_adopter!(account, sessions: [created_session])
203
+ end
196
204
  message = error.message
197
205
  # When raised without arguments, StandardError#message returns the class name
198
206
  message = "Sign-in was denied" if message.blank? || message == error.class.name
@@ -208,25 +216,10 @@ module StandardId
208
216
 
209
217
  # Destroy a newly created account and all its dependents.
210
218
  # Used when after_sign_in rejects a just-created account to avoid orphans.
211
- #
212
- # Every association read goes through `.strict_loading(false)`: the account
213
- # was built in this request, so none of its associations are loaded, and a
214
- # host running `strict_loading_by_default = true` (most consumers) would
215
- # otherwise raise StrictLoadingViolationError on `account.sessions` — turning
216
- # a hook's clean rejection of a new signup into a 500 and leaving the
217
- # orphaned account behind. Relation-level `strict_loading(false)` also covers
218
- # the records it loads, so `identifier.credentials` below is safe too.
219
+ # See StandardId::AccountCleanup (shared with the API token grants, which
220
+ # do the same when the login-method policy refuses a new account).
219
221
  def destroy_newly_created_account(account)
220
- return unless account&.persisted?
221
-
222
- ActiveRecord::Base.transaction do
223
- account.sessions.strict_loading(false).destroy_all
224
- identifiers = account.identifiers.strict_loading(false).to_a
225
- identifiers.each { |i| i.credentials.strict_loading(false).destroy_all }
226
- # Deliberately destroy! (unlike the destroy_all calls above): a failed identifier destroy raises and rolls back the whole cleanup, failing loud instead of leaving a half-cleaned orphan.
227
- identifiers.each(&:destroy!)
228
- account.destroy
229
- end
222
+ StandardId::AccountCleanup.destroy_newly_created!(account)
230
223
  end
231
224
 
232
225
  # Resolve the active scope name for the current request.