standard_id 0.54.1 → 0.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +123 -0
  4. data/app/controllers/standard_id/api/email_password_controller.rb +131 -0
  5. data/app/forms/standard_id/web/reset_password_confirm_form.rb +8 -8
  6. data/app/jobs/standard_id/purge_unverified_email_signups_job.rb +141 -0
  7. data/app/mailers/standard_id/account_notice_mailer.rb +33 -0
  8. data/app/models/standard_id/identifier.rb +22 -2
  9. data/app/models/standard_id/session.rb +4 -4
  10. data/app/views/standard_id/account_notice_mailer/existing_account.html.erb +22 -0
  11. data/app/views/standard_id/account_notice_mailer/existing_account.text.erb +10 -0
  12. data/config/locales/en.yml +8 -0
  13. data/config/routes/api.rb +14 -0
  14. data/db/migrate/20261022000000_add_signup_source_to_standard_id_identifiers.rb +13 -0
  15. data/lib/standard_id/access_revocation.rb +40 -0
  16. data/lib/standard_id/config/callable_validator.rb +49 -0
  17. data/lib/standard_id/config/schema.rb +108 -0
  18. data/lib/standard_id/email_password/authentication.rb +71 -0
  19. data/lib/standard_id/email_password/daily_limit.rb +67 -0
  20. data/lib/standard_id/email_password/password_reset.rb +160 -0
  21. data/lib/standard_id/email_password/registration.rb +141 -0
  22. data/lib/standard_id/email_password/token_flow.rb +36 -0
  23. data/lib/standard_id/email_password/verification.rb +155 -0
  24. data/lib/standard_id/email_password.rb +209 -0
  25. data/lib/standard_id/errors.rb +39 -0
  26. data/lib/standard_id/login_method_policy.rb +2 -1
  27. data/lib/standard_id/version.rb +1 -1
  28. data/lib/standard_id.rb +8 -0
  29. metadata +15 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '047082209f6ec1b345a5500438fb424ef343c2e72f922b063ed876124b0ad957'
4
- data.tar.gz: 00f773c75baff568d12c3caa98f56b49c4630da093808cf7a8afb82e5a66807a
3
+ metadata.gz: 79b115262b448690a7d1429716da8b36e6422be79e9406868684a3744e0cf3e8
4
+ data.tar.gz: a43dc5ed9ea2ff5bd5317809f79c9326d1df14a2115f63e631090a7fe15042cd
5
5
  SHA512:
6
- metadata.gz: 9a2c6bb133b7eca3d39545b666b0d82b6ef03f59374110618fee49643dceaf8c839871761e188a1aa4f1f3e5c0b3dfebc28a3a61d68f4cc12fa051337f33de8c
7
- data.tar.gz: 6aebcee090d61b6094f25b724e3a89bb73e7424227f2bef24e4b17606e175a081673b6e6c296a31374ab22661f5d7b0f998d9e4585aa0ba0330d2d684537f3ce
6
+ metadata.gz: a184a92b4a8f865db6e4fd4f05cbaa9e44d1156407ed61d30530ee6379269cc9cc3da0d9f64b800c7fc44461abc28d2df44ebe2d4562a6dbde96d0f69a106ec7
7
+ data.tar.gz: abc3fe5d187c94408406cfeba9e27a699f5542b30a1979c268c7b5fe7d2986ea29b56dcb6d4b8399b8e0df3a7ed56271951a49eec9a25076d5cd568a1a30b9c5
data/CHANGELOG.md CHANGED
@@ -7,6 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.55.1] - 2026-10-23
11
+
12
+ ### Fixed
13
+
14
+ - **`credential.password.reset_completed` from `POST /auth/password/reset` carries a
15
+ loaded credential again.** The reset's row lock dropped the preloaded
16
+ `credential → identifier → account`, so under strict loading a subscriber that walked
17
+ `event[:credential].credential.identifier` raised, silently, since the publish is
18
+ rescued. The chain is now preloaded again before the event is published.
19
+ - **A `reset_completed` subscriber that raises is now reported via `Rails.error`**
20
+ (`handled: true, source: "standard_id"`) as well as logged, on both the API reset and
21
+ the web reset form, so it reaches the host's error tracker. The reset still succeeds.
22
+
23
+ ## [0.55.0] - 2026-10-22
24
+
25
+ ### Added
26
+
27
+ - **Email + password API, opt-in (`c.email_password.enabled`).** `POST /auth/register`,
28
+ `/auth/verify-email`, `/auth/verify-email/resend`, `/auth/login`,
29
+ `/auth/password/forgot` and `/auth/password/reset` under the API mount, for JSON
30
+ clients. Sign-up creates an unverified account and emails a 6-digit code;
31
+ verification needs the code and the sign-up password and returns the usual token
32
+ response, refreshable through `/oauth/token`. Login answers a distinct `unverified`
33
+ only to the right password. Resend and forgot always answer `202`. A reset verifies
34
+ the address and revokes every session and refresh token. Re-submitting a pending
35
+ sign-up never changes its password. Errors are stable `StandardId::EmailPasswordError`
36
+ codes, with an `error_renderer` hook for hosts that keep their own envelope. Short
37
+ limits reuse `c.rate_limits`, plus new daily per-address caps
38
+ (`rate_limits.email_password_*`). The operations behind it
39
+ (`StandardId::EmailPassword::*`) are public. Migration
40
+ **`20261022000000_add_signup_source_to_standard_id_identifiers`** adds the
41
+ nullable `signup_source` column that marks these sign-ups; run it before enabling
42
+ the API. See the README and `docs/MIGRATION_GUIDE.md`.
43
+ - **`c.email_password.reveal_existing_accounts`** (default `true`: `409
44
+ account_exists` / `social_account`). `false` answers sign-up for a registered address
45
+ like a new one and emails the owner (`AccountNoticeMailer#existing_account`).
46
+ - **`StandardId::PurgeUnverifiedEmailSignupsJob`** removes sign-ups made through the
47
+ email + password API (`signup_source` `"email_password"`; never the web sign-up form's) left unverified for `c.email_password.unverified_purge_after` (3 days), skipping any
48
+ account showing use. Schedule it yourself (`docs/OPERATIONS.md`).
49
+ - **`StandardId::EmailPassword.discard_pending_credentials!`**, for a
50
+ `social.account.linked` subscriber: drops a stranger's pending password from an
51
+ account its owner has just proven with a provider.
52
+ - **`StandardId::AccessRevocation.revoke_all!(account, reason:, issued_before: nil)`**
53
+ revokes every session and every refresh token, including sessionless ones; the web
54
+ password reset now uses it.
55
+ - **`login_method_policy` flow `:api_email_password`.**
56
+ - **`Identifier#verify!(defer_event: true)`** publishes
57
+ `IDENTIFIER_VERIFICATION_SUCCEEDED` after the enclosing transaction commits (and not
58
+ at all if it rolls back). The default is unchanged.
59
+
60
+ ### Changed
61
+
62
+ - **`SESSION_REVOKED` timing on a web password reset** (from 0.54.1, now documented):
63
+ the events publish inside the reset's transaction, before it commits. Each is
64
+ rescued, so a subscriber cannot roll the reset back, but one that reads the rows
65
+ must do so after commit (from a job, say).
66
+ - **`passwordless.bypass_code` applies to the web email/phone verification pages**
67
+ (from 0.54.1, which routed them through `Otp.verify`). It is never accepted in
68
+ production, which raises.
69
+
10
70
  ## [0.54.1] - 2026-10-22
11
71
 
12
72
  ### Security
data/README.md CHANGED
@@ -1614,6 +1614,7 @@ usual "invalid" answer and never reaches the policy.
1614
1614
  | `:oauth_social_callback` (`/api/oauth/callback/:provider`) | `:social` + `provider` | `403 access_denied`; a just-created account is removed |
1615
1615
  | `: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 |
1616
1616
  | `:api_device_session` / `:api_service_session` (`Api::TokenManager#create_*_session` from host code) | what you pass, else `:unspecified` | raises `LoginMethodDenied` |
1617
+ | `:api_email_password` (`/api/auth/login`, `/api/auth/verify-email`) | `:password` | `403 {"error":"login_method_denied"}` (through `error_renderer` when set) |
1617
1618
 
1618
1619
  **Refresh tokens remember how their authentication was made.** The method
1619
1620
  (and provider) is recorded at sign-in and carried along: web sign-in → the
@@ -1674,6 +1675,127 @@ POST /api/passwordless/verify
1674
1675
  }
1675
1676
  ```
1676
1677
 
1678
+ ### Email + password API (0.55+)
1679
+
1680
+ Opt-in sign-up and sign-in with an email and a password for JSON clients
1681
+ (mobile apps, SPAs), alongside the OAuth grants. Off by default; the routes are
1682
+ not drawn until you enable it. It needs migration
1683
+ `20261022000000_add_signup_source_to_standard_id_identifiers`, so run
1684
+ `bin/rails standard_id:install:migrations && bin/rails db:migrate` before
1685
+ enabling it.
1686
+
1687
+ ```ruby
1688
+ StandardId.configure do |c|
1689
+ c.email_password.enabled = true
1690
+ c.email_password.reset_url = "https://app.example.com/password-reset?token={token}"
1691
+
1692
+ # Optional hooks (each may declare any subset of its keywords):
1693
+ c.email_password.require_name = true
1694
+ c.email_password.client_key = ->(request:) { MaskedNetwork.for(request) } # default: remote IP
1695
+ c.email_password.password_validator = ->(password:) { PwnedPasswords.check(password) } # => Array of messages
1696
+ c.email_password.after_email_verified = ->(account:, name:) { Profile.provision!(account, name) }
1697
+ c.email_password.account_in_use = ->(account:) { Profile.exists?(account: account) }
1698
+ end
1699
+ ```
1700
+
1701
+ | Request (under the API mount) | Body | Success |
1702
+ |---|---|---|
1703
+ | `POST /auth/register` | `email`, `password`, `name?` | `202 {"status":"verification_required","email":…}` |
1704
+ | `POST /auth/verify-email` | `email`, `code`, `password` | `200` token response |
1705
+ | `POST /auth/verify-email/resend` | `email`, `password` | `202 {"status":"verification_sent"}`, always |
1706
+ | `POST /auth/login` | `email`, `password` | `200` token response |
1707
+ | `POST /auth/password/forgot` | `email` | `202 {"status":"reset_requested"}`, always |
1708
+ | `POST /auth/password/reset` | `token`, `password`, `password_confirmation?` | `200 {"status":"password_reset"}` |
1709
+
1710
+ Requests must be JSON. Token responses are the usual `TokenResponse` and
1711
+ refresh through `POST /oauth/token` with `client_id` =
1712
+ `c.email_password.token_client_id` (default `"email"`).
1713
+
1714
+ The rules:
1715
+
1716
+ - **Sign-up creates an unverified account** and emails a 6-digit code
1717
+ (`PasswordlessMailer#verification_email`, or your own
1718
+ `PASSWORDLESS_CODE_GENERATED` subscriber with `code_delivery = :custom`). No
1719
+ tokens until the address is verified.
1720
+ - **Verifying needs the code and the sign-up password**, so a stranger who
1721
+ started a sign-up for someone else's address cannot finish it with the
1722
+ owner's code. A wrong password answers exactly like a wrong code. Outside
1723
+ production, `passwordless.bypass_code` is accepted as the code (as everywhere
1724
+ `Otp.verify` runs); `after_email_verified` then receives no `name`.
1725
+ - **Re-submitting sign-up for a pending address never changes its password.**
1726
+ With the same password it sends a fresh code; with a different one it answers
1727
+ the same `202` and sends nothing.
1728
+ - **Login answers `unverified` (403) only when the password is right**;
1729
+ everything else (unknown address, wrong password, deactivated or locked
1730
+ account) is one `invalid_credentials` (401).
1731
+ - **Resend and forgot-password answer the same whatever the address.** The
1732
+ reset lookup happens in `PasswordResetDeliveryJob`.
1733
+ - **A reset link** sets the password, verifies the address (the link proved the
1734
+ inbox; a pending sign-up's `after_email_verified` runs then) and revokes every
1735
+ session and refresh token. It is single-use and
1736
+ expires with `PasswordCredential`'s `password_reset` token.
1737
+ - **Existing accounts:** sign-up for an address with a verified password
1738
+ account answers `409 account_exists`, and for a social-only account `409
1739
+ social_account`. Set `c.email_password.reveal_existing_accounts = false` to
1740
+ answer those like a new address instead and email the owner
1741
+ (`AccountNoticeMailer#existing_account`).
1742
+ - **`login_method_policy`** is consulted with `flow: :api_email_password`.
1743
+ - **Hooks and events.** `ACCOUNT_CREATED` and `CREDENTIAL_PASSWORD_CREATED`
1744
+ fire at sign-up, for an address that is not verified yet; provision anything
1745
+ that should wait for a proven address from `after_email_verified`. That hook
1746
+ runs once, when the address is first verified (by code or reset link), in the
1747
+ verification's transaction: if it raises, the address stays unverified and the
1748
+ registrant retries with a new code or link. It runs only for a sign-up made
1749
+ through this API (its identifier's `signup_source` is `"email_password"`): a
1750
+ web sign-up verified here, by code or reset link, is verified without it. It runs with the address's rows
1751
+ locked, so keep slow work (an external call) in a job it enqueues.
1752
+
1753
+ `IDENTIFIER_VERIFICATION_SUCCEEDED` is published only after that transaction
1754
+ commits. A failing subscriber to it is logged, not raised, since the
1755
+ verification has already committed. Tokens are minted under the password
1756
+ credential's row lock, so a reset racing a login or a verification either
1757
+ lands first (that request then fails) or revokes what it minted.
1758
+
1759
+ **Errors** are `StandardId::EmailPasswordError` codes, rendered as
1760
+ `{"error": code, "error_description": message, "details"?: {field: [messages]}}`:
1761
+
1762
+ | Code | Status |
1763
+ |---|---|
1764
+ | `invalid_credentials` | 401 |
1765
+ | `unverified`, `login_method_denied` | 403 |
1766
+ | `account_exists`, `social_account` | 409 |
1767
+ | `invalid_code`, `invalid_reset_token`, `weak_password`, `invalid_request` | 422 |
1768
+ | `rate_limited` (with `Retry-After`) | 429 |
1769
+
1770
+ To keep an existing envelope, return your own body from
1771
+ `c.email_password.error_renderer = ->(error:, request:) { … }`; the status stays
1772
+ `error.http_status`.
1773
+
1774
+ **Limits.** Each endpoint has short per-client-key and per-address limits that
1775
+ reuse `c.rate_limits` (`signup_per_ip`, `login_per_*`, `verification_start_*`,
1776
+ `password_reset_start_*`, `otp_verify_per_ip`) plus
1777
+ `email_password_verify_per_email` and `email_password_reset_per_ip`; like the
1778
+ engine's other `rate_limit` declarations they are read when the controller
1779
+ loads, so set them in an initializer. Daily per-address caps sit on top:
1780
+ `email_password_failed_verifications_per_day` (then verify is refused for the
1781
+ day, even with the right code), `email_password_codes_per_day`,
1782
+ `email_password_reset_requests_per_network_per_day` and
1783
+ `email_password_reset_requests_per_day`. All of them count in
1784
+ `c.cache_store`, so a `NullStore` disables them. Only a wrong code sent with the
1785
+ right password counts toward the daily verify lockout, but the short
1786
+ `email_password_verify_per_email` limit counts every request, so anyone can
1787
+ hold an address's verification off for that window.
1788
+
1789
+ **Housekeeping.** Schedule `StandardId::PurgeUnverifiedEmailSignupsJob` (see
1790
+ [docs/OPERATIONS.md](docs/OPERATIONS.md)). If you link social sign-ins by
1791
+ email, call `StandardId::EmailPassword.discard_pending_credentials!(account,
1792
+ issued_before:)` from a `social.account.linked` subscriber, so an owner who
1793
+ signs in with a provider does not inherit a stranger's pending password.
1794
+
1795
+ The operations behind the controller (`EmailPassword::Registration`,
1796
+ `Authentication`, `Verification`, `PasswordReset`, `TokenFlow`) are public, so
1797
+ a host can drive them from its own controllers instead.
1798
+
1677
1799
  ### API Authentication
1678
1800
 
1679
1801
  ```ruby
@@ -1770,6 +1892,7 @@ StandardId creates the following tables:
1770
1892
  - `GET /userinfo` - OpenID Connect userinfo
1771
1893
  - `POST /passwordless/start` - Start passwordless flow
1772
1894
  - `POST /passwordless/verify` - Verify OTP code
1895
+ - `POST /auth/register`, `/auth/login`, `/auth/verify-email`, `/auth/verify-email/resend`, `/auth/password/forgot`, `/auth/password/reset` - Email + password, when `c.email_password.enabled`
1773
1896
  - `GET /oauth/callback/google` - Google OAuth callback
1774
1897
  - `POST /oauth/callback/apple` - Apple Sign In callback
1775
1898
 
@@ -0,0 +1,131 @@
1
+ module StandardId
2
+ module Api
3
+ # Email + password sign-up and sign-in for JSON clients. Drawn only while
4
+ # `c.email_password.enabled` is true; the rules are in StandardId::EmailPassword.
5
+ #
6
+ # POST /auth/register { email, password, name? }
7
+ # 202 { status: "verification_required", email }
8
+ # POST /auth/login { email, password }
9
+ # 200 token response
10
+ # POST /auth/verify-email { email, code, password }
11
+ # 200 token response
12
+ # POST /auth/verify-email/resend { email, password }
13
+ # 202 { status: "verification_sent" }, ALWAYS
14
+ # POST /auth/password/forgot { email }
15
+ # 202 { status: "reset_requested" }, ALWAYS
16
+ # POST /auth/password/reset { token, password, password_confirmation? }
17
+ # 200 { status: "password_reset" }
18
+ #
19
+ # Token responses are the TokenResponse every other grant returns, and
20
+ # refresh through /oauth/token with `c.email_password.token_client_id`.
21
+ #
22
+ # Failures are StandardId::EmailPasswordError codes, rendered as
23
+ # `{ error, error_description, details? }` with the code's status, or by
24
+ # `c.email_password.error_renderer` when set.
25
+ #
26
+ # Every action is limited per client key (`c.email_password.client_key`,
27
+ # default the remote IP) and, where there is one, per target email.
28
+ class EmailPasswordController < BaseController
29
+ public_controller
30
+
31
+ RATE_LIMITS = {
32
+ register: { ip: [:signup_per_ip, 1.hour], email: [:verification_start_per_target, 15.minutes] },
33
+ login: { ip: [:login_per_ip, 15.minutes], email: [:login_per_email, 15.minutes] },
34
+ verify: { ip: [:otp_verify_per_ip, 15.minutes], email: [:email_password_verify_per_email, 15.minutes] },
35
+ resend: { ip: [:verification_start_per_ip, 1.hour], email: [:verification_start_per_target, 15.minutes] },
36
+ forgot_password: { ip: [:password_reset_start_per_ip, 1.hour], email: [:password_reset_start_per_target, 15.minutes] },
37
+ reset_password: { ip: [:email_password_reset_per_ip, 15.minutes] }
38
+ }.freeze
39
+
40
+ RATE_LIMITS.each do |action, limits|
41
+ limits.each do |kind, (field, within)|
42
+ rate_limit to: StandardId.config.rate_limits.public_send(field),
43
+ within: within,
44
+ name: "email-password-#{action}-#{kind}",
45
+ only: action,
46
+ by: kind == :ip ? -> { rate_limit_client_key } : -> { "email:#{rate_limit_email.presence || rate_limit_client_key}" },
47
+ with: -> { raise StandardId::EmailPasswordError.new(:rate_limited, retry_after: within.to_i) },
48
+ store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
49
+ end
50
+ end
51
+
52
+ rescue_from StandardId::EmailPasswordError, with: :render_email_password_error
53
+
54
+ def register
55
+ email = StandardId::EmailPassword::Registration.call(
56
+ email: params[:email], password: params[:password], name: params[:name], request: request
57
+ )
58
+ render json: { status: "verification_required", email: email }, status: :accepted
59
+ end
60
+
61
+ def login
62
+ StandardId::EmailPassword::Authentication.call(email: params[:email], password: params[:password]) do |account|
63
+ render_tokens_for(account)
64
+ end
65
+ end
66
+
67
+ def verify
68
+ StandardId::EmailPassword::Verification.verify!(
69
+ email: params[:email], code: params[:code], password: params[:password], request: request
70
+ ) do |account|
71
+ render_tokens_for(account)
72
+ end
73
+ end
74
+
75
+ def resend
76
+ StandardId::EmailPassword::Verification.send_code(
77
+ email: params[:email], password: params[:password], request: request
78
+ )
79
+ render json: { status: "verification_sent" }, status: :accepted
80
+ end
81
+
82
+ def forgot_password
83
+ StandardId::EmailPassword::PasswordReset.request(email: params[:email], request: request)
84
+ render json: { status: "reset_requested" }, status: :accepted
85
+ end
86
+
87
+ def reset_password
88
+ StandardId::EmailPassword::PasswordReset.complete!(
89
+ token: params[:token],
90
+ password: params[:password],
91
+ password_confirmation: params.key?(:password_confirmation) ? params[:password_confirmation] : nil,
92
+ request: request
93
+ )
94
+ render json: { status: "password_reset" }, status: :ok
95
+ end
96
+
97
+ private
98
+
99
+ def render_tokens_for(account)
100
+ render json: StandardId::EmailPassword::TokenFlow.new(account: account, request: request).execute, status: :ok
101
+ rescue StandardId::AccountDeactivatedError, StandardId::AccountLockedError
102
+ # The same answer as a wrong password: this endpoint must not reveal
103
+ # the account's state.
104
+ raise StandardId::EmailPasswordError.new(:invalid_credentials)
105
+ rescue StandardId::LoginMethodDenied => e
106
+ # The password was right, so the policy's reason may be shown.
107
+ raise StandardId::EmailPasswordError.new(:login_method_denied, e.message)
108
+ end
109
+
110
+ def render_email_password_error(error)
111
+ response.set_header("Retry-After", error.retry_after.to_i.to_s) if error.retry_after.to_i.positive?
112
+
113
+ renderer = StandardId::EmailPassword.config.error_renderer
114
+ body = if renderer.respond_to?(:call)
115
+ StandardId::EmailPassword.call_hook(renderer, error: error, request: request)
116
+ else
117
+ { error: error.code.to_s, error_description: error.message, details: error.details.presence }.compact
118
+ end
119
+ render json: body, status: error.http_status
120
+ end
121
+
122
+ def rate_limit_client_key
123
+ StandardId::EmailPassword.client_key(request).to_s
124
+ end
125
+
126
+ def rate_limit_email
127
+ StandardId::EmailPassword.normalize_email(params[:email])
128
+ end
129
+ end
130
+ end
131
+ end
@@ -46,7 +46,7 @@ module StandardId
46
46
  private
47
47
 
48
48
  # The reset has already happened, so a failing subscriber must not turn
49
- # it into an error page; log and carry on, as Session does for
49
+ # it into an error page; report and carry on, as Session does for
50
50
  # SESSION_REVOKED.
51
51
  def publish_reset_completed(account)
52
52
  StandardId::Events.publish(
@@ -55,6 +55,7 @@ module StandardId
55
55
  account: account
56
56
  )
57
57
  rescue StandardError => e
58
+ Rails.error.report(e, handled: true, source: "standard_id")
58
59
  logger = StandardId.logger
59
60
  return unless logger.respond_to?(:error)
60
61
 
@@ -64,14 +65,13 @@ module StandardId
64
65
  )
65
66
  end
66
67
 
67
- # Revoke every session (and the refresh tokens under them), then any
68
- # refresh token with no session (e.g. one minted by a grant flow). Revoking
69
- # rather than destroying keeps the audit trail and fires SESSION_REVOKED;
70
- # sessions.destroy_all used to nullify refresh_tokens.session_id
71
- # (dependent: :nullify), leaving those tokens active after the reset.
68
+ # Revoke every session and refresh token (StandardId::AccessRevocation).
69
+ # Revoking rather than destroying keeps the audit trail and fires
70
+ # SESSION_REVOKED; sessions.destroy_all used to nullify
71
+ # refresh_tokens.session_id (dependent: :nullify), leaving those tokens
72
+ # active after the reset.
72
73
  def revoke_access!(account)
73
- StandardId::Session.revoke_all_for!(account, reason: "password_reset")
74
- StandardId::RefreshToken.where(account_id: account.id, revoked_at: nil).update_all(revoked_at: Time.current)
74
+ StandardId::AccessRevocation.revoke_all!(account, reason: "password_reset")
75
75
  end
76
76
  end
77
77
  end
@@ -0,0 +1,141 @@
1
+ module StandardId
2
+ # Deletes email + password sign-ups (StandardId::EmailPassword) that were
3
+ # never verified. A no-op unless `c.email_password.enabled`; not part of
4
+ # CleanupAllJob, so schedule it yourself (daily is plenty).
5
+ #
6
+ # A sign-up that sits unverified is dead weight at best and, at worst, a
7
+ # stranger squatting someone else's address (see
8
+ # EmailPassword::Registration). After `c.email_password.unverified_purge_after`
9
+ # seconds (3 days by default, far beyond the code TTL and the daily code cap)
10
+ # the pending password credential and its email identifier are removed, and
11
+ # the account too when nothing else is left on it.
12
+ #
13
+ # Only an email identifier that is unverified, has no provider, carries a
14
+ # password credential, is older than the cutoff AND was created by
15
+ # EmailPassword::Registration (`signup_source`, EmailPassword.api_signup?)
16
+ # is considered. That last condition keeps the job away from other
17
+ # unverified password sign-ups, such as Web::SignupForm's.
18
+ # Even then the whole account is skipped, unchanged, at any sign of real use:
19
+ # * any verified identifier, or any with a provider (a social sign-in
20
+ # linked to it does not verify the identifier, so the provider is
21
+ # checked separately);
22
+ # * any session or refresh token;
23
+ # * `c.email_password.account_in_use` returning true (e.g. the host's
24
+ # profile rows);
25
+ # * any other row still referencing the account: the delete is guarded by
26
+ # restrict / foreign keys, and a guard failure rolls the unit back.
27
+ # A sign-up whose owner asked for a code within
28
+ # `c.email_password.purge_recent_code_window` seconds is kept, so someone
29
+ # who comes back on day 4 is not deleted minutes before typing the code in.
30
+ # So is one whose address asked for a password reset in the last 24 hours
31
+ # (EmailPassword::DailyLimit's counter; a reset leaves no row), so the
32
+ # link in flight still works. That check is only as good as the rate-limit
33
+ # store: with a null store it never fires.
34
+ # Each sign-up is re-checked under a row lock in its own transaction, so a
35
+ # verification that lands mid-run wins. The locks are taken in the order
36
+ # verify and reset take them (password credential, then identifier); a
37
+ # deadlock or lock wait timeout anyway leaves the sign-up for the next run.
38
+ class PurgeUnverifiedEmailSignupsJob < ApplicationJob
39
+ queue_as :default
40
+
41
+ # @param older_than_seconds [Integer, nil] defaults to
42
+ # c.email_password.unverified_purge_after
43
+ # @return [Hash] `{ purged_count:, skipped_count: }`
44
+ def perform(older_than_seconds: nil)
45
+ return { purged_count: 0, skipped_count: 0 } unless StandardId::EmailPassword.enabled?
46
+
47
+ cutoff = (older_than_seconds || config.unverified_purge_after).to_i.seconds.ago
48
+ purged = 0
49
+ skipped = 0
50
+
51
+ candidates(cutoff).find_each do |identifier|
52
+ if purge(identifier, cutoff)
53
+ purged += 1
54
+ else
55
+ skipped += 1
56
+ end
57
+ end
58
+
59
+ Rails.logger.info("[StandardId] Purged #{purged} unverified email sign-ups older than #{cutoff} (skipped #{skipped})")
60
+ { purged_count: purged, skipped_count: skipped }
61
+ end
62
+
63
+ private
64
+
65
+ def config
66
+ StandardId::EmailPassword.config
67
+ end
68
+
69
+ def candidates(cutoff)
70
+ StandardId::EmailIdentifier
71
+ .where(verified_at: nil, provider: nil, signup_source: StandardId::EmailPassword::SIGNUP_SOURCE)
72
+ .where(created_at: ...cutoff)
73
+ .where(id: StandardId::Credential.where(credentialable_type: StandardId::PasswordCredential.name).select(:identifier_id))
74
+ end
75
+
76
+ # true when the sign-up was removed, false when it was left alone.
77
+ def purge(identifier, cutoff)
78
+ ActiveRecord::Base.transaction do
79
+ lock_password_credentials(identifier)
80
+ identifier.lock!
81
+ return false unless identifier.verified_at.nil? && identifier.provider.nil? && identifier.created_at < cutoff &&
82
+ StandardId::EmailPassword.api_signup?(identifier)
83
+
84
+ account = StandardId.account_class.find_by(id: identifier.account_id)
85
+ return false if account.nil? || account_in_use?(account)
86
+ return false if recent_code_requested?(identifier.value) || reset_requested?(identifier.value)
87
+
88
+ credentials = StandardId::Credential.where(identifier_id: identifier.id).to_a
89
+ return false unless credentials.all? { |c| c.credentialable_type == StandardId::PasswordCredential.name }
90
+
91
+ password_credentials = StandardId::PasswordCredential.where(id: credentials.map(&:credentialable_id)).to_a
92
+ credentials.each(&:destroy!)
93
+ password_credentials.each(&:destroy!)
94
+ identifier.destroy!
95
+
96
+ unless StandardId::Identifier.exists?(account_id: account.id)
97
+ # The dependent-destroy cascade lazy-loads associations, which a host
98
+ # running strict_loading_by_default would refuse.
99
+ account.strict_loading!(false)
100
+ account.destroy!
101
+ end
102
+ true
103
+ end
104
+ rescue ActiveRecord::DeleteRestrictionError, ActiveRecord::InvalidForeignKey, ActiveRecord::RecordNotDestroyed,
105
+ ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout => e
106
+ Rails.logger.warn("[StandardId] Left unverified email sign-up #{identifier.id} alone: #{e.class}")
107
+ false
108
+ end
109
+
110
+ def lock_password_credentials(identifier)
111
+ StandardId::PasswordCredential
112
+ .where(id: StandardId::Credential
113
+ .where(identifier_id: identifier.id, credentialable_type: StandardId::PasswordCredential.name)
114
+ .select(:credentialable_id))
115
+ .order(:id)
116
+ .lock
117
+ .to_a
118
+ end
119
+
120
+ def account_in_use?(account)
121
+ return true if StandardId::Identifier.where(account_id: account.id).where("verified_at IS NOT NULL OR provider IS NOT NULL").exists?
122
+ return true if StandardId::Session.where(account_id: account.id).exists?
123
+ return true if StandardId::RefreshToken.where(account_id: account.id).exists?
124
+
125
+ hook = config.account_in_use
126
+ # Any truthy answer keeps the account: when in doubt, keep.
127
+ hook.respond_to?(:call) && StandardId::EmailPassword.call_hook(hook, account: account) ? true : false
128
+ end
129
+
130
+ def reset_requested?(email)
131
+ StandardId::EmailPassword::DailyLimit.count(:reset_requested, email).positive?
132
+ end
133
+
134
+ def recent_code_requested?(email)
135
+ StandardId::CodeChallenge
136
+ .where(realm: config.verification_realm, target: StandardId::EmailPassword.normalize_email(email))
137
+ .where(created_at: config.purge_recent_code_window.to_i.seconds.ago..)
138
+ .exists?
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,33 @@
1
+ module StandardId
2
+ # Notices about an account, sent to its address rather than to whoever made
3
+ # the request.
4
+ #
5
+ # * #existing_account: sign-up was attempted for an address that already has
6
+ # an account, while `c.email_password.reveal_existing_accounts` is false
7
+ # (the API then answers as if the sign-up went ahead). The owner learns
8
+ # about it and how to get in; the requester learns nothing.
9
+ #
10
+ # Copy lives under `standard_id.account_notice_mailer.<action>.*` in
11
+ # config/locales/en.yml; override any key in the host's locale files, or the
12
+ # templates in app/views/standard_id/account_notice_mailer/.
13
+ class AccountNoticeMailer < ApplicationMailer
14
+ layout false
15
+
16
+ helper_method :notice_copy
17
+
18
+ # @param email [String]
19
+ def existing_account
20
+ @email = params[:email]
21
+
22
+ mail(to: @email, from: StandardId.config.passwordless.mailer_from, subject: notice_copy(:subject))
23
+ end
24
+
25
+ # Copy for the current action, falling back to the gem's English (see
26
+ # PasswordlessMailer#otp_copy).
27
+ def notice_copy(key, default: nil, **options)
28
+ scope = "standard_id.account_notice_mailer.#{action_name}"
29
+ english = I18n.t(key, scope: scope, locale: :en, default: default, **options)
30
+ I18n.t(key, scope: scope, default: english, **options)
31
+ end
32
+ end
33
+ end
@@ -20,9 +20,17 @@ module StandardId
20
20
  verified_at.present?
21
21
  end
22
22
 
23
- def verify!
23
+ # +defer_event+ holds IDENTIFIER_VERIFICATION_SUCCEEDED until the
24
+ # enclosing transaction commits, and drops it if that rolls back. The
25
+ # verification has committed by the time it fires, so a failing
26
+ # subscriber is logged rather than raised.
27
+ def verify!(defer_event: false)
24
28
  update!(verified_at: Time.current)
25
- emit_verification_succeeded
29
+ if defer_event
30
+ ActiveRecord.after_all_transactions_commit { emit_verification_succeeded_after_commit }
31
+ else
32
+ emit_verification_succeeded
33
+ end
26
34
  end
27
35
 
28
36
  def unverify!
@@ -63,6 +71,18 @@ module StandardId
63
71
  )
64
72
  end
65
73
 
74
+ def emit_verification_succeeded_after_commit
75
+ emit_verification_succeeded
76
+ rescue StandardError => e
77
+ logger = StandardId.logger
78
+ return unless logger.respond_to?(:error)
79
+
80
+ logger.error(
81
+ "[StandardId::Identifier] Failed to publish IDENTIFIER_VERIFICATION_SUCCEEDED " \
82
+ "for identifier #{id}: #{e.class}: #{e.message}"
83
+ )
84
+ end
85
+
66
86
  def emit_account_verified
67
87
  StandardId::Events.publish(
68
88
  StandardId::Events::ACCOUNT_VERIFIED,
@@ -176,10 +176,10 @@ module StandardId
176
176
  )
177
177
  end
178
178
 
179
- # DB state is already committed by the time this runs (unless the caller
180
- # wraps revoke_all_for! in its own transaction, as Web::ResetPasswordConfirmForm
181
- # does); event publishing is
182
- # best-effort audit emission. A failing subscriber must not short-circuit the
179
+ # DB state is already committed by the time this runs, unless the caller
180
+ # wraps revoke_all_for! in its own transaction, as StandardId::AccessRevocation
181
+ # does for the password-reset paths (Web::ResetPasswordConfirmForm and
182
+ # EmailPassword::PasswordReset). Event publishing is best-effort audit emission. A failing subscriber must not short-circuit the
183
183
  # loop and leave later sessions without their SESSION_REVOKED event, which
184
184
  # would permanently desync audit-trail consumers from the DB.
185
185
  def self.publish_session_revocations(sessions, account:, reason:, now:)
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <style>
6
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f5f5f5; margin: 0; padding: 0; }
7
+ .container { max-width: 480px; margin: 40px auto; background-color: #ffffff; border-radius: 8px; padding: 40px; }
8
+ .footer { margin-top: 32px; font-size: 13px; color: #888888; text-align: center; }
9
+ </style>
10
+ </head>
11
+ <body>
12
+ <div class="container">
13
+ <p><%= notice_copy(:greeting) %></p>
14
+ <p><%= notice_copy(:intro) %></p>
15
+ <p><%= notice_copy(:action) %></p>
16
+ <p><%= notice_copy(:ignore) %></p>
17
+ <div class="footer">
18
+ <p><%= notice_copy(:footer) %></p>
19
+ </div>
20
+ </div>
21
+ </body>
22
+ </html>
@@ -0,0 +1,10 @@
1
+ <%= notice_copy(:greeting) %>
2
+
3
+ <%= notice_copy(:intro) %>
4
+
5
+ <%= notice_copy(:action) %>
6
+
7
+ <%= notice_copy(:ignore) %>
8
+
9
+ --
10
+ <%= notice_copy(:footer) %>