standard_id 0.44.0 → 0.45.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +123 -2
  4. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +3 -18
  5. data/app/controllers/concerns/standard_id/social_authentication.rb +208 -11
  6. data/app/controllers/concerns/standard_id/web_authentication.rb +6 -1
  7. data/app/controllers/standard_id/api/authorization_controller.rb +11 -4
  8. data/app/controllers/standard_id/api/base_controller.rb +16 -0
  9. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +61 -15
  10. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +51 -3
  11. data/app/controllers/standard_id/web/consent_controller.rb +5 -1
  12. data/app/controllers/standard_id/web/login_verify_controller.rb +6 -1
  13. data/app/controllers/standard_id/web/signup_controller.rb +6 -1
  14. data/db/migrate/20261004000000_add_auth_method_to_standard_id_refresh_tokens.rb +17 -0
  15. data/lib/generators/standard_id/install/templates/standard_id.rb +13 -0
  16. data/lib/standard_id/account_cleanup.rb +62 -0
  17. data/lib/standard_id/api/token_manager.rb +18 -2
  18. data/lib/standard_id/auth_lineage.rb +75 -0
  19. data/lib/standard_id/config/callable_validator.rb +5 -0
  20. data/lib/standard_id/config/schema.rb +14 -0
  21. data/lib/standard_id/errors.rb +55 -0
  22. data/lib/standard_id/events/definitions.rb +6 -1
  23. data/lib/standard_id/events/subscribers/logging_subscriber.rb +1 -0
  24. data/lib/standard_id/login_method_policy.rb +108 -0
  25. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +2 -1
  26. data/lib/standard_id/oauth/authorization_code_flow.rb +6 -0
  27. data/lib/standard_id/oauth/authorization_flow.rb +6 -2
  28. data/lib/standard_id/oauth/password_flow.rb +13 -2
  29. data/lib/standard_id/oauth/passwordless_otp_flow.rb +4 -0
  30. data/lib/standard_id/oauth/refresh_token_flow.rb +32 -0
  31. data/lib/standard_id/oauth/social_flow.rb +4 -0
  32. data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +6 -1
  33. data/lib/standard_id/oauth/token_grant_flow.rb +54 -2
  34. data/lib/standard_id/providers/base.rb +34 -0
  35. data/lib/standard_id/version.rb +1 -1
  36. data/lib/standard_id/web/session_manager.rb +50 -3
  37. data/lib/standard_id/web/token_manager.rb +8 -3
  38. data/lib/standard_id.rb +3 -0
  39. 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: 7acc907df7682568d732664fb09873ab023b696e4057095b261a318aab7543b1
4
+ data.tar.gz: f20929b44d2669f600e304345dbc34992fe9df54b9ff7e40fd64b45731a117e1
5
5
  SHA512:
6
- metadata.gz: a17095049de6adf0c32413fad6322a58b2f50b77c5db052043a74f082e40e6374518d22dad2518eeb4a01c15921865af993b51bae46447a8ee0135e968e6ec14
7
- data.tar.gz: 2262c87410fd09684f885818aca1f31de00fe6f12daad4db0c6706521acf4d852ee2bdc623e15cc83da1aea9a7fbb7001eaab0129331ff93904056055cef4e9d
6
+ metadata.gz: ac1c4b0e6148140f310456a71fa9e88e162750aa35707b945c2b7c0061a19b1cae54a0cc2fb33b8b418d151c6bfd60190f49669c9cb14dedb159c6bb3b448602
7
+ data.tar.gz: 99ddde4158e6354df56d377ed3508cb4111207492b62f12ffcb9c6276a862c0bd0e7cbbcc22e41a026b3073db3252325d6a354be903e3413bf5431ac8ea9f45d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.45.0] - 2026-10-04
11
+
12
+ Two opt-in hooks that the org-IdP provider plugin (`standard_id-void_which_binds`) needs. With neither configured, behaviour is unchanged.
13
+
14
+ ### Added
15
+
16
+ - **`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:
17
+ - the provider must report `email_verified` as `true` / `"true"` (`:email_unverified` otherwise);
18
+ - the existing email identifier must itself be verified, closing pre-account hijacking (`:link_required` otherwise);
19
+ - the identifier must not be linked to a different `sub` from that provider (`:subject_mismatch`);
20
+ - a stored `(provider, sub)` match still wins over any email match.
21
+
22
+ 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".
23
+ - **`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:
24
+ - web: `/login` password, `/signup`, `/login_verify` passwordless, the social callback, remember-me re-authentication, and host calls to `session_manager.sign_in_account`;
25
+ - 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`.
26
+
27
+ 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".
28
+ - **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:`.
29
+ - **`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.
30
+ - `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`.
31
+ - `StandardId::AccountCleanup.destroy_newly_created!`, extracted from `LifecycleHooks#destroy_newly_created_account` so the API grants can share it.
32
+
33
+ ### Changed
34
+
35
+ - **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.
36
+ - **`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.
37
+ - **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`).
38
+ - 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`.
39
+
40
+ ### Upgrade notes
41
+
42
+ 1. Run `bin/rails standard_id:install:migrations && bin/rails db:migrate` (one new migration: two nullable columns on `standard_id_refresh_tokens`).
43
+ 2. Nothing changes until you set `c.login_method_policy` or a provider plugin returns `trusted_for_linking? == true`.
44
+ 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`).
45
+ 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.
46
+
47
+ ### Fixed
48
+
49
+ - **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`.
50
+ - **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.
51
+
10
52
  ## [0.44.0] - 2026-10-02
11
53
 
12
54
  **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,6 +1526,7 @@ 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+) |
1409
1530
 
1410
1531
  **Protected helpers** for use inside those methods — signatures are stable:
1411
1532
 
@@ -208,25 +208,10 @@ module StandardId
208
208
 
209
209
  # Destroy a newly created account and all its dependents.
210
210
  # 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.
211
+ # See StandardId::AccountCleanup (shared with the API token grants, which
212
+ # do the same when the login-method policy refuses a new account).
219
213
  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
214
+ StandardId::AccountCleanup.destroy_newly_created!(account)
230
215
  end
231
216
 
232
217
  # Resolve the active scope name for the current request.
@@ -38,7 +38,9 @@ module StandardId
38
38
  # reports is not consulted.
39
39
  # 2. The email matches an existing EmailIdentifier → link to that account,
40
40
  # but only when
41
- # - the link_strategy allows it (validate_social_link!),
41
+ # - the link_strategy allows it (validate_social_link!; under :strict a
42
+ # provider that is trusted_for_linking? may link across providers
43
+ # to a verified identifier),
42
44
  # - the identifier is not already linked to a DIFFERENT sub from this
43
45
  # provider (possible takeover), and
44
46
  # - the provider reports the email as verified. Without that, the
@@ -70,9 +72,9 @@ module StandardId
70
72
  validate_social_link!(identifier, provider)
71
73
  validate_social_subject!(identifier, provider, subject)
72
74
  validate_social_email_verified!(identifier, provider, social_info)
73
- identifier.update!(provider: provider.provider_name) if identifier.provider.nil?
74
- record_social_identity!(identifier, subject)
75
- emit_social_account_linked(identifier.account, provider, identifier)
75
+ # SOCIAL_ACCOUNT_LINKED is published by commit_social_link!, once the
76
+ # link has been written — never for a staged link that is dropped.
77
+ stage_social_link!(identifier, subject, backfill_provider: identifier.provider.nil?, emit_linked: true)
76
78
  identifier.account
77
79
  else
78
80
  account = build_account_from_social(social_info)
@@ -82,7 +84,7 @@ module StandardId
82
84
  provider: provider.provider_name
83
85
  )
84
86
  identifier.verify! if identifier.respond_to?(:verify!) && social_email_verified?(social_info)
85
- record_social_identity!(identifier, subject)
87
+ stage_social_link!(identifier, subject, backfill_provider: false)
86
88
  emit_social_account_created(account, provider, social_info)
87
89
  account
88
90
  end
@@ -103,10 +105,29 @@ module StandardId
103
105
  return if identifier.provider.nil?
104
106
  return if identifier.provider == provider.provider_name
105
107
  return if account_has_social_identifier_from?(identifier.account, provider)
108
+ return if trusted_cross_provider_link?(identifier, provider)
106
109
 
107
110
  refuse_social_link!(identifier, provider, :link_required)
108
111
  end
109
112
 
113
+ # The provider opted in via Providers::Base.trusted_for_linking? (only the
114
+ # org's own IdP, whose email claims the org verifies, should) AND the
115
+ # existing identifier's address is itself verified. This only lifts the
116
+ # :strict cross-provider refusal: validate_social_subject! and
117
+ # validate_social_email_verified! still run after it, so an unverified
118
+ # provider email or a different sub is refused exactly as before.
119
+ #
120
+ # The verified-identifier requirement closes pre-account hijacking: an
121
+ # account someone registered for an address they never proved must not
122
+ # be handed to the address's real owner arriving via the trusted IdP
123
+ # (the registrant would keep their own way in).
124
+ def trusted_cross_provider_link?(identifier, provider)
125
+ return false unless provider.respond_to?(:trusted_for_linking?)
126
+ return false unless provider.trusted_for_linking? == true
127
+
128
+ identifier.respond_to?(:verified?) && identifier.verified?
129
+ end
130
+
110
131
  # The identifier is already linked to another subject from this provider:
111
132
  # a second provider account is claiming the same address.
112
133
  def validate_social_subject!(identifier, provider, subject)
@@ -159,17 +180,193 @@ module StandardId
159
180
  StandardId::SocialIdentity.includes(:account, :identifier).find_by(provider: provider.provider_name, subject: subject)
160
181
  end
161
182
 
183
+ # Writes the (provider, sub) row inside its own savepoint (it runs inside
184
+ # commit_social_link!'s transaction; on PostgreSQL a failed INSERT would
185
+ # otherwise abort it).
186
+ #
187
+ # Find-then-insert is spelled out rather than left to find_or_create_by!,
188
+ # because a concurrent login can commit a rival row at any point in it and
189
+ # each point surfaces differently: before the SELECT (the row is found),
190
+ # between the SELECT and validation (the uniqueness validations raise
191
+ # RecordInvalid), or between validation and the INSERT (the unique index
192
+ # raises RecordNotUnique; find_or_create_by! would turn an
193
+ # (identifier, provider) collision into RecordNotFound, and return a
194
+ # (provider, sub) winner for ANY account). Every one of them is classified
195
+ # by classify_social_link_race!.
162
196
  def record_social_identity!(identifier, subject)
163
197
  return if subject.nil?
164
198
  return unless social_identities_available?
165
199
 
166
- StandardId::SocialIdentity.find_or_create_by!(
167
- provider: provider.provider_name,
168
- subject: subject
169
- ) do |social_identity|
170
- social_identity.account = identifier.account
171
- social_identity.identifier = identifier
200
+ attributes = { provider: provider.provider_name, subject: subject }
201
+ existing = StandardId::SocialIdentity.find_by(attributes)
202
+ return classify_social_link_race!(identifier, subject) if existing
203
+
204
+ StandardId::SocialIdentity.transaction(requires_new: true) do
205
+ StandardId::SocialIdentity.create!(attributes.merge(account: identifier.account, identifier: identifier))
206
+ end
207
+ rescue ActiveRecord::RecordNotUnique
208
+ classify_social_link_race!(identifier, subject) || raise
209
+ rescue ActiveRecord::RecordInvalid => e
210
+ raise unless social_link_race_invalid?(e.record)
211
+
212
+ classify_social_link_race!(identifier, subject) || raise
213
+ end
214
+
215
+ # A RecordInvalid that is only the uniqueness validations losing a race.
216
+ # Anything else (a missing account, a host validation) is a real error.
217
+ def social_link_race_invalid?(record)
218
+ details = record&.errors&.details
219
+ return false if details.blank?
220
+
221
+ details.all? do |attribute, errors|
222
+ %i[subject identifier_id].include?(attribute) && errors.all? { |error| error[:error] == :taken }
223
+ end
224
+ end
225
+
226
+ # Another login has linked first. Returns the winning row when it links
227
+ # this (provider, sub) to the same account (adopt it); raises
228
+ # SocialLinkConflictError when it links this sub to ANOTHER account
229
+ # (:subject_conflict) or this identifier to this provider under ANOTHER
230
+ # sub (:subject_mismatch); returns nil when no rival is found (it was
231
+ # removed again), so the caller re-raises the original error.
232
+ def classify_social_link_race!(identifier, subject)
233
+ winner = StandardId::SocialIdentity.find_by(provider: provider.provider_name, subject: subject)
234
+ if winner
235
+ return winner if winner.account_id == identifier.account_id
236
+
237
+ raise StandardId::SocialLinkConflictError.new(SOCIAL_RETRY_MESSAGE, identifier: identifier, reason: :subject_conflict)
238
+ end
239
+
240
+ other_sub = StandardId::SocialIdentity.where(identifier_id: identifier.id, provider: provider.provider_name).where.not(subject: subject)
241
+ return nil unless other_sub.exists?
242
+
243
+ raise StandardId::SocialLinkConflictError.new(SOCIAL_RETRY_MESSAGE, identifier: identifier, reason: :subject_mismatch)
244
+ end
245
+
246
+ # The (provider, sub) link and the provider backfill are STAGED, not
247
+ # written, by find_or_create_account_from_social when the caller defers
248
+ # them (the web and API callbacks do), and written by commit_social_link!
249
+ # only once the login has been accepted. A rejected login therefore never
250
+ # writes a link, so rejecting it never has to delete one — and cannot
251
+ # delete a row a concurrent, successful callback for the same
252
+ # (provider, sub) has created or adopted in the meantime.
253
+ #
254
+ # Callers that do not defer (the default, e.g. host code calling
255
+ # find_or_create_account_from_social directly) get the link written
256
+ # immediately, as before.
257
+ def stage_social_link!(identifier, subject, backfill_provider:, emit_linked: false)
258
+ @pending_social_link = {
259
+ identifier: identifier,
260
+ subject: subject,
261
+ backfill_provider: backfill_provider,
262
+ emit_linked: emit_linked
263
+ }
264
+ commit_social_link! unless defer_social_link?
265
+ end
266
+
267
+ def defer_social_link?
268
+ false
269
+ end
270
+
271
+ # Writes the staged link in one transaction: the provider backfill and
272
+ # the (provider, sub) row land together or not at all, and the staged
273
+ # state is kept until they have, so a failure part-way leaves nothing to
274
+ # reverse. A block, when given, runs inside that transaction after the
275
+ # writes: work the login still has to get through for the link to stand
276
+ # (the web callback's redirect_to, which can raise). It runs even when
277
+ # nothing is staged. SOCIAL_ACCOUNT_LINKED is published only after the
278
+ # transaction has committed.
279
+ #
280
+ # The account row is locked first and must still exist. That serialises
281
+ # this commit with AccountCleanup.destroy_newly_created!, which takes the
282
+ # same lock: an account another, rejected request created and is now
283
+ # removing is never linked to (this login fails and can simply be
284
+ # retried, creating a fresh account), and an account this login has
285
+ # already signed in to is never removed under it (see AccountCleanup).
286
+ def commit_social_link!
287
+ pending = @pending_social_link
288
+ if pending.nil?
289
+ yield if block_given?
290
+ return
291
+ end
292
+
293
+ identifier = pending[:identifier]
294
+ begin
295
+ write_social_link!(pending, identifier) { yield if block_given? }
296
+ rescue StandardId::SocialLinkConflictError => e
297
+ # A concurrent login won the link (see the error's reasons).
298
+ # Published here, once the transaction has rolled back, so a
299
+ # subscriber that writes an audit row is not rolled back with the
300
+ # link; and only on this path, so never for a link that commits.
301
+ emit_social_link_blocked(identifier, provider, e.reason)
302
+ raise
172
303
  end
304
+
305
+ @pending_social_link = nil
306
+ if pending[:backfill_provider]
307
+ identifier.provider = provider.provider_name
308
+ identifier.clear_attribute_changes([:provider]) if identifier.respond_to?(:clear_attribute_changes)
309
+ end
310
+ emit_committed_social_link(identifier, provider) if pending[:emit_linked]
311
+ end
312
+
313
+ # The transaction behind commit_social_link!.
314
+ def write_social_link!(pending, identifier)
315
+ ActiveRecord::Base.transaction do
316
+ if StandardId.account_class.lock.where(id: identifier.account_id).pick(:id).nil?
317
+ raise StandardId::InvalidGrantError, SOCIAL_RETRY_MESSAGE
318
+ end
319
+
320
+ if pending[:backfill_provider]
321
+ # Conditional, so a provider set concurrently is never overwritten.
322
+ StandardId::Identifier.where(id: identifier.id, provider: nil).update_all(provider: provider.provider_name)
323
+ end
324
+ record_social_identity!(identifier, pending[:subject])
325
+ yield if block_given?
326
+ end
327
+ end
328
+
329
+ # SOCIAL_ACCOUNT_LINKED reports a link that has already committed, so it
330
+ # cannot refuse the login: a subscriber that raises here would fail the
331
+ # callback while the link stays. Its error is logged and reported to
332
+ # Rails.error instead of propagating. (Guards belong on the events that
333
+ # run before anything is written.)
334
+ def emit_committed_social_link(identifier, provider)
335
+ emit_social_account_linked(identifier.account, provider, identifier)
336
+ rescue StandardError => e
337
+ Rails.logger&.error("[StandardId] SOCIAL_ACCOUNT_LINKED subscriber raised after the link committed: #{e.class}: #{e.message}")
338
+ Rails.error.report(e, handled: true, source: "standard_id")
339
+ end
340
+
341
+ # A social login that recorded a link (and maybe created an account) and
342
+ # was then rejected for ANY reason — policy, hook, invalid scope, audience
343
+ # binding, an unexpected error — must leave nothing behind: drop the link
344
+ # and remove an account this request created. Safe to call when nothing
345
+ # was recorded, and when the account is already gone.
346
+ def discard_social_attempt!(account, newly_created:)
347
+ rollback_social_link!
348
+ StandardId::AccountCleanup.destroy_newly_created!(account) if newly_created
349
+ end
350
+
351
+ # The login matched an account by email that a concurrent, refused
352
+ # request had created and has since removed (AccountCleanup). The login
353
+ # then fails part-way (its session insert hits the foreign key, or
354
+ # commit_social_link! finds the account gone); the callbacks report that
355
+ # as a retryable invalid_grant rather than a 500. A retry finds no
356
+ # account and creates a fresh one. Check before discard_social_attempt!,
357
+ # which removes an account THIS request created.
358
+ def social_account_removed_concurrently?(account, newly_created:)
359
+ return false if newly_created || account.nil? || account.id.nil?
360
+
361
+ !StandardId.account_class.where(id: account.id).exists?
362
+ end
363
+
364
+ SOCIAL_RETRY_MESSAGE = "The sign-in could not be completed. Please try again.".freeze
365
+
366
+ # Nothing was written for a deferred link, so dropping the staged one is
367
+ # the whole rollback. (A non-deferring caller has already committed it.)
368
+ def rollback_social_link!
369
+ @pending_social_link = nil
173
370
  end
174
371
 
175
372
  def social_identities_available?
@@ -154,7 +154,12 @@ module StandardId
154
154
  # but before session creation. The block may raise AuthenticationDenied.
155
155
  before_session&.call(password_credential.account)
156
156
 
157
- session_manager.sign_in_account(password_credential.account, scope_name: request.path_parameters[:scope])
157
+ session_manager.sign_in_account(
158
+ password_credential.account,
159
+ scope_name: request.path_parameters[:scope],
160
+ auth_method: :password,
161
+ flow: :web_password
162
+ )
158
163
  session_manager.set_remember_cookie(password_credential) if remember_me
159
164
 
160
165
  StandardId::Events.publish(
@@ -19,7 +19,11 @@ module StandardId
19
19
  reject_invalid_redirect_uri!
20
20
  return redirect_to_consent if consent_required?
21
21
 
22
- response_data = flow_strategy_class.new(flow_strategy_params, request, current_account: current_account).execute
22
+ response_data = flow_strategy_class.new(
23
+ flow_strategy_params, request,
24
+ current_account: current_account,
25
+ auth_lineage: StandardId::AuthLineage.from_session(authorization_session_manager.current_session)
26
+ ).execute
23
27
 
24
28
  if response_data[:redirect_to]
25
29
  redirect_to response_data[:redirect_to], status: response_data[:status] || :found, allow_other_host: true
@@ -150,10 +154,13 @@ module StandardId
150
154
  end
151
155
 
152
156
  def current_account
153
- @current_account ||= begin
157
+ @current_account ||= authorization_session_manager.current_account
158
+ end
159
+
160
+ def authorization_session_manager
161
+ @authorization_session_manager ||= begin
154
162
  token_manager = StandardId::Web::TokenManager.new(request)
155
- session_manager = StandardId::Web::SessionManager.new(token_manager, request: request, session: session, cookies: cookies)
156
- session_manager.current_account
163
+ StandardId::Web::SessionManager.new(token_manager, request: request, session: session, cookies: cookies)
157
164
  end
158
165
  end
159
166
  end
@@ -26,6 +26,11 @@ module StandardId
26
26
  rescue_from StandardId::AccountDeactivatedError, with: :handle_account_deactivated
27
27
  rescue_from StandardId::AccountLockedError, with: :handle_account_locked
28
28
 
29
+ # config.login_method_policy refused the method (token grants, social
30
+ # callback). Not an OAuthError — it is an AuthenticationDenied so the
31
+ # WebEngine's existing handling covers it — so it is mapped here.
32
+ rescue_from StandardId::LoginMethodDenied, with: :handle_login_method_denied
33
+
29
34
  protected
30
35
 
31
36
  def validate_content_type!
@@ -74,6 +79,17 @@ module StandardId
74
79
  render_bearer_unauthorized!(error_description: "The account is locked")
75
80
  end
76
81
 
82
+ # `403 access_denied`, the shape SocialLinkError already uses on the API
83
+ # callback. The policy only runs after the credential was proven, so the
84
+ # caller already controls it and the policy's message (e.g. "Staff must
85
+ # sign in with ...") tells them nothing they could not learn otherwise.
86
+ def handle_login_method_denied(error)
87
+ render json: {
88
+ error: error.oauth_error_code.to_s,
89
+ error_description: error.message
90
+ }, status: error.http_status
91
+ end
92
+
77
93
  def handle_oauth_error(error)
78
94
  render json: {
79
95
  error: error.oauth_error_code,