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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +123 -2
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +3 -18
- data/app/controllers/concerns/standard_id/social_authentication.rb +208 -11
- 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/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 +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 +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/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 +5 -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,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
|
|
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
|
-
#
|
|
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.
|
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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(
|
|
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(
|
|
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 ||=
|
|
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
|
-
|
|
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,
|