standard_id 0.43.2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +148 -3
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +3 -18
- data/app/controllers/concerns/standard_id/social_authentication.rb +316 -8
- data/app/controllers/concerns/standard_id/web_authentication.rb +6 -1
- data/app/controllers/standard_id/api/authorization_controller.rb +11 -4
- data/app/controllers/standard_id/api/base_controller.rb +16 -0
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +61 -15
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +51 -3
- data/app/controllers/standard_id/web/consent_controller.rb +5 -1
- data/app/controllers/standard_id/web/login_verify_controller.rb +6 -1
- data/app/controllers/standard_id/web/signup_controller.rb +6 -1
- data/app/models/standard_id/social_identity.rb +30 -0
- data/db/migrate/20261002000000_create_standard_id_social_identities.rb +44 -0
- data/db/migrate/20261004000000_add_auth_method_to_standard_id_refresh_tokens.rb +17 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +21 -4
- data/lib/standard_id/account_cleanup.rb +62 -0
- data/lib/standard_id/api/token_manager.rb +18 -2
- data/lib/standard_id/auth_lineage.rb +75 -0
- data/lib/standard_id/config/callable_validator.rb +5 -0
- data/lib/standard_id/config/schema.rb +18 -0
- data/lib/standard_id/errors.rb +68 -2
- data/lib/standard_id/events/definitions.rb +6 -1
- data/lib/standard_id/events/subscribers/logging_subscriber.rb +1 -0
- data/lib/standard_id/login_method_policy.rb +108 -0
- data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +2 -1
- data/lib/standard_id/oauth/authorization_code_flow.rb +6 -0
- data/lib/standard_id/oauth/authorization_flow.rb +6 -2
- data/lib/standard_id/oauth/password_flow.rb +13 -2
- data/lib/standard_id/oauth/passwordless_otp_flow.rb +4 -0
- data/lib/standard_id/oauth/refresh_token_flow.rb +32 -0
- data/lib/standard_id/oauth/social_flow.rb +4 -0
- data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +6 -1
- data/lib/standard_id/oauth/token_grant_flow.rb +54 -2
- data/lib/standard_id/providers/base.rb +34 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web/session_manager.rb +50 -3
- data/lib/standard_id/web/token_manager.rb +8 -3
- data/lib/standard_id.rb +3 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7acc907df7682568d732664fb09873ab023b696e4057095b261a318aab7543b1
|
|
4
|
+
data.tar.gz: f20929b44d2669f600e304345dbc34992fe9df54b9ff7e40fd64b45731a117e1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ac1c4b0e6148140f310456a71fa9e88e162750aa35707b945c2b7c0061a19b1cae54a0cc2fb33b8b418d151c6bfd60190f49669c9cb14dedb159c6bb3b448602
|
|
7
|
+
data.tar.gz: 99ddde4158e6354df56d377ed3508cb4111207492b62f12ffcb9c6276a862c0bd0e7cbbcc22e41a026b3073db3252325d6a354be903e3413bf5431ac8ea9f45d
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,78 @@ 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
|
+
|
|
52
|
+
## [0.44.0] - 2026-10-02
|
|
53
|
+
|
|
54
|
+
**Security fix (L1-01): social login could take over an existing account by email.** Behaviour change and a new migration; see **Upgrade notes**.
|
|
55
|
+
|
|
56
|
+
### Security
|
|
57
|
+
|
|
58
|
+
- **Social logins are now matched on the provider's stable subject id (`sub`), and linking to an existing account requires a provider-verified email.** `SocialAuthentication#find_or_create_account_from_social` used to look up `EmailIdentifier.find_by(value: email)` and sign the login in to that account. It never looked at `sub`, and `email_verified` only decided whether a *new* identifier was marked verified. Under `link_strategy: :trust_provider`, or under the default `:strict` for any email identifier with a `NULL` provider (every identifier created before migration `20260311000000` added the column, and every identifier the gem creates outside social login: password signup, passwordless, `find_or_create_by_verified_email!`), a provider token that merely *claimed* an address, verified or not, signed in as that address's account. Both the web callback (`Web::Auth::Callback::ProvidersController`) and the API callback (`Api::Oauth::Callback::ProvidersController` → `Oauth::SocialFlow`) went through this method; `SocialFlow` and `SocialLoginGrant` themselves do no account lookup. Resolution is now:
|
|
59
|
+
1. `(provider, sub)` matches a `StandardId::SocialIdentity` → that account, whatever email the provider now reports.
|
|
60
|
+
2. The email matches an existing identifier → link only if the `link_strategy` allows it, the identifier is not already linked to a **different** `sub` from the same provider, and the provider reports `email_verified` as `true` or `"true"` (Apple and Google's tokeninfo send the string). This holds under `:trust_provider` too. A successful link stores the `sub`.
|
|
61
|
+
3. Otherwise a new account is created exactly as before (an unverified provider email still creates an unverified identifier), and the `sub` is stored.
|
|
62
|
+
|
|
63
|
+
Each refusal emits `SOCIAL_LINK_BLOCKED` and raises `StandardId::SocialLinkError` instead of creating a duplicate account. The web callback already turns that error into a redirect to `/login` with an alert; the API callback returns `403 access_denied`.
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- **`standard_id_social_identities`** (migration `20261002000000_create_standard_id_social_identities`) and the `StandardId::SocialIdentity` model: `account_id`, `identifier_id` (the email identifier the link was made through), `provider`, `subject`, unique on `(provider, subject)` and on `(identifier_id, provider)`. Both foreign keys are `ON DELETE CASCADE`, so deleting an identifier or account needs no new step.
|
|
68
|
+
- `StandardId::SocialLinkError#reason`: `:link_required` (the strict strategy refused, as before), `:email_unverified` or `:subject_mismatch`. `SOCIAL_LINK_BLOCKED` carries the same `reason`. The constructor's new `reason:` keyword is optional and defaults to `:link_required`, so code that builds the error itself is unaffected.
|
|
69
|
+
|
|
70
|
+
### Changed
|
|
71
|
+
|
|
72
|
+
- `email_verified` is read strictly: only `true` or `"true"` (any case) count. Google's OAuth2 v2 userinfo key `verified_email` is accepted when `email_verified` is absent, because standard_id-google ≤ 0.5.0 returns that endpoint's JSON unchanged on its code and access-token paths.
|
|
73
|
+
|
|
74
|
+
### Upgrade notes
|
|
75
|
+
|
|
76
|
+
1. **Install and run the migration**: `bin/rails standard_id:install:migrations && bin/rails db:migrate`. It creates one new, empty table; nothing existing is rewritten and nothing is backfilled. Existing social users get their row on their next login (on a verified email). Until it has run, the gem logs a warning once per process, skips subject matching, and still enforces the verified-email requirement; the boot-time missing-migration check and `StandardId::Checks::Migrations` also report it.
|
|
77
|
+
2. **Upgrade standard_id-google to 0.6.0** alongside this release if you use it. Its code-exchange and access-token paths read Google's v2 userinfo endpoint, which returns `id` / `verified_email` rather than `sub` / `email_verified`, so on ≤ 0.5.0 those logins carry no `sub` (they still work, matched by verified email every time). 0.6.0 adds `sub` and `email_verified`. standard_id-apple already returns both from the verified ID token.
|
|
78
|
+
3. **Expect refusals where a link used to go through silently.** A social login whose provider reports the email as unverified can no longer attach to an existing account (it previously did under `:trust_provider`, or for a `NULL`-provider identifier). Google and Apple report `email_verified: true` for the address on almost every account, so this should be rare; subscribe to `SOCIAL_LINK_BLOCKED` and read `reason` to see it. A host that builds custom copy from `SocialLinkError` may want per-`reason` messages.
|
|
79
|
+
4. `link_strategy` keeps its meaning (`:strict` default, `:trust_provider`); no config change is needed.
|
|
80
|
+
|
|
81
|
+
|
|
10
82
|
## [0.43.2] - 2026-09-25
|
|
11
83
|
|
|
12
84
|
### Fixed
|
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
|
|
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 |
|
|
@@ -1165,6 +1166,149 @@ redirect_to "/api/authorize?" + {
|
|
|
1165
1166
|
}.to_query
|
|
1166
1167
|
```
|
|
1167
1168
|
|
|
1169
|
+
#### How a social login finds its account (0.44+)
|
|
1170
|
+
|
|
1171
|
+
1. **Provider + subject.** A login whose `(provider, sub)` is stored in
|
|
1172
|
+
`standard_id_social_identities` signs in to that account. The email the
|
|
1173
|
+
provider reports is not consulted.
|
|
1174
|
+
2. **Existing email identifier.** Otherwise, if the email belongs to an
|
|
1175
|
+
existing account, the login links to it only when all of these hold, and
|
|
1176
|
+
raises `StandardId::SocialLinkError` (after emitting `SOCIAL_LINK_BLOCKED`)
|
|
1177
|
+
when one does not. The error's `reason` says which:
|
|
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);
|
|
1182
|
+
- the identifier is not already linked to a **different** `sub` from the
|
|
1183
|
+
same provider (`:subject_mismatch`);
|
|
1184
|
+
- the provider reports the email as verified: `email_verified` is `true`
|
|
1185
|
+
or the string `"true"` (`:email_unverified`). This applies under
|
|
1186
|
+
`:trust_provider` too.
|
|
1187
|
+
|
|
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`.
|
|
1193
|
+
3. **New account.** Otherwise a new account is created, as before, and the
|
|
1194
|
+
`sub` is stored.
|
|
1195
|
+
|
|
1196
|
+
Providers must return the OIDC `sub` and `email_verified` claims in
|
|
1197
|
+
`user_info`. A provider that returns no `sub` still works, but is matched on
|
|
1198
|
+
email (with the verified-email requirement) every time.
|
|
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
|
+
|
|
1168
1312
|
### Passwordless Authentication
|
|
1169
1313
|
|
|
1170
1314
|
```ruby
|
|
@@ -1382,6 +1526,7 @@ end
|
|
|
1382
1526
|
| `flow_for(params)` | `:web` only for `flow=web` on providers that `supports_mobile_callback?`, else `:mobile` | Flow for the API callback |
|
|
1383
1527
|
| `skip_csrf?` | `false` | `true` for POST (form_post) callbacks |
|
|
1384
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+) |
|
|
1385
1530
|
|
|
1386
1531
|
**Protected helpers** for use inside those methods — signatures are stable:
|
|
1387
1532
|
|
|
@@ -1503,12 +1648,12 @@ StandardId never deletes expired rows on its own. Four cleanup jobs do, and **al
|
|
|
1503
1648
|
|
|
1504
1649
|
| Job | Deletes | Grace windows (`perform` kwargs) | Recommended cadence |
|
|
1505
1650
|
|---|---|---|---|
|
|
1506
|
-
| `StandardId::CleanupExpiredSessionsJob` | browser/device/service sessions expired > grace | `grace_period_seconds:` 7 days | hourly (minute 6) |
|
|
1651
|
+
| `StandardId::CleanupExpiredSessionsJob` | browser/device/service sessions expired > grace and holding no live refresh token (dead tokens are detached, not deleted) | `grace_period_seconds:` 7 days; `batch_size:` 1,000 | hourly (minute 6) |
|
|
1507
1652
|
| `StandardId::CleanupExpiredRefreshTokensJob` | refresh tokens expired or revoked > grace | `grace_period_seconds:` 7 days | hourly (minute 3) |
|
|
1508
1653
|
| `StandardId::CleanupExpiredAuthorizationCodesJob` | OAuth authorization codes expired > 7 days or consumed > 1 day | `grace_period_seconds:`, `consumed_grace_period_seconds:` | hourly (minute 9) |
|
|
1509
1654
|
| `StandardId::CleanupExpiredCodeChallengesJob` | OTP code challenges expired > 7 days or used > 1 day | `grace_period_seconds:`, `used_grace_period_seconds:` | hourly (minute 13) |
|
|
1510
1655
|
|
|
1511
|
-
Retention is bounded by the grace windows, not the cadence
|
|
1656
|
+
Retention is bounded by the grace windows, not the cadence. Three of the jobs are a single `DELETE`, so running hourly keeps that statement small on busy tables (daily is fine for small apps). `CleanupExpiredSessionsJob` instead works in batches of `batch_size:` sessions: one transaction per batch, holding a `SELECT … FOR UPDATE SKIP LOCKED` on the candidates, an `UPDATE` detaching their dead refresh tokens and the `DELETE`. It never deletes a session whose refresh token is still live, because refresh tokens outlive session expiry by design; such a session goes on a later run, once its tokens are dead. Stagger the jobs off minute 0.
|
|
1512
1657
|
|
|
1513
1658
|
`rails g standard_id:install` adds all four to `config/recurring.yml` (Solid Queue) under `production:` when that file exists (`--skip-recurring` to opt out; re-running is a no-op). An engine cannot register Solid Queue recurring tasks itself — Solid Queue reads one schedule file — so existing apps should paste this under their `production:` key:
|
|
1514
1659
|
|
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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.
|