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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +167 -3
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +12 -19
- data/app/controllers/concerns/standard_id/social_authentication.rb +267 -13
- data/app/controllers/concerns/standard_id/web/social_login_params.rb +6 -2
- 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 +72 -16
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +69 -6
- data/app/controllers/standard_id/web/consent_controller.rb +5 -1
- data/app/controllers/standard_id/web/login_controller.rb +8 -0
- data/app/controllers/standard_id/web/login_verify_controller.rb +6 -1
- data/app/controllers/standard_id/web/signup_controller.rb +6 -1
- 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 +13 -0
- data/lib/standard_id/account_cleanup.rb +147 -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 +14 -0
- data/lib/standard_id/errors.rb +55 -0
- 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/social_login_grant.rb +6 -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 +87 -2
- 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 +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2a825be521399d2fbb91a820cbc0da601d24865a12fc549d1a8697ea1d6c97cb
|
|
4
|
+
data.tar.gz: da555da47e6a9ae99f1c888e5ef93c2fc4a02d21b11993300c67d02df6ef5d01
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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.
|