standard_id 0.54.1 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -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 +6 -7
  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 +148 -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: 1b6e3c72dcbb1a915c4927a4f0607b13d6f66c791240e20825698b490b0f13a1
4
+ data.tar.gz: 9606c37fbf368616167772d7a32fdadc7b4abb16758c9041b7041694b697ffb4
5
5
  SHA512:
6
- metadata.gz: 9a2c6bb133b7eca3d39545b666b0d82b6ef03f59374110618fee49643dceaf8c839871761e188a1aa4f1f3e5c0b3dfebc28a3a61d68f4cc12fa051337f33de8c
7
- data.tar.gz: 6aebcee090d61b6094f25b724e3a89bb73e7424227f2bef24e4b17606e175a081673b6e6c296a31374ab22661f5d7b0f998d9e4585aa0ba0330d2d684537f3ce
6
+ metadata.gz: b31012a34cc04b983d754b506f13d227a046dd5a33a53774b071d2822e4d51aba4745d685a25f0d84c7c55b7c37e79378351a4efbc556b91831368095282d7d4
7
+ data.tar.gz: e0ad4c58dfeb705e5824da097c12b6a00b6d60450f0c248a3f0a1874fe912f5549ecebfdbd1dd199aa6810af6668fea7f8562c6ee7dad0b076f29635e36ffd44
data/CHANGELOG.md CHANGED
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.55.0] - 2026-10-22
11
+
12
+ ### Added
13
+
14
+ - **Email + password API, opt-in (`c.email_password.enabled`).** `POST /auth/register`,
15
+ `/auth/verify-email`, `/auth/verify-email/resend`, `/auth/login`,
16
+ `/auth/password/forgot` and `/auth/password/reset` under the API mount, for JSON
17
+ clients. Sign-up creates an unverified account and emails a 6-digit code;
18
+ verification needs the code and the sign-up password and returns the usual token
19
+ response, refreshable through `/oauth/token`. Login answers a distinct `unverified`
20
+ only to the right password. Resend and forgot always answer `202`. A reset verifies
21
+ the address and revokes every session and refresh token. Re-submitting a pending
22
+ sign-up never changes its password. Errors are stable `StandardId::EmailPasswordError`
23
+ codes, with an `error_renderer` hook for hosts that keep their own envelope. Short
24
+ limits reuse `c.rate_limits`, plus new daily per-address caps
25
+ (`rate_limits.email_password_*`). The operations behind it
26
+ (`StandardId::EmailPassword::*`) are public. Migration
27
+ **`20261022000000_add_signup_source_to_standard_id_identifiers`** adds the
28
+ nullable `signup_source` column that marks these sign-ups; run it before enabling
29
+ the API. See the README and `docs/MIGRATION_GUIDE.md`.
30
+ - **`c.email_password.reveal_existing_accounts`** (default `true`: `409
31
+ account_exists` / `social_account`). `false` answers sign-up for a registered address
32
+ like a new one and emails the owner (`AccountNoticeMailer#existing_account`).
33
+ - **`StandardId::PurgeUnverifiedEmailSignupsJob`** removes sign-ups made through the
34
+ 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
35
+ account showing use. Schedule it yourself (`docs/OPERATIONS.md`).
36
+ - **`StandardId::EmailPassword.discard_pending_credentials!`**, for a
37
+ `social.account.linked` subscriber: drops a stranger's pending password from an
38
+ account its owner has just proven with a provider.
39
+ - **`StandardId::AccessRevocation.revoke_all!(account, reason:, issued_before: nil)`**
40
+ revokes every session and every refresh token, including sessionless ones; the web
41
+ password reset now uses it.
42
+ - **`login_method_policy` flow `:api_email_password`.**
43
+ - **`Identifier#verify!(defer_event: true)`** publishes
44
+ `IDENTIFIER_VERIFICATION_SUCCEEDED` after the enclosing transaction commits (and not
45
+ at all if it rolls back). The default is unchanged.
46
+
47
+ ### Changed
48
+
49
+ - **`SESSION_REVOKED` timing on a web password reset** (from 0.54.1, now documented):
50
+ the events publish inside the reset's transaction, before it commits. Each is
51
+ rescued, so a subscriber cannot roll the reset back, but one that reads the rows
52
+ must do so after commit (from a job, say).
53
+ - **`passwordless.bypass_code` applies to the web email/phone verification pages**
54
+ (from 0.54.1, which routed them through `Otp.verify`). It is never accepted in
55
+ production, which raises.
56
+
10
57
  ## [0.54.1] - 2026-10-22
11
58
 
12
59
  ### 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
@@ -64,14 +64,13 @@ module StandardId
64
64
  )
65
65
  end
66
66
 
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.
67
+ # Revoke every session and refresh token (StandardId::AccessRevocation).
68
+ # Revoking rather than destroying keeps the audit trail and fires
69
+ # SESSION_REVOKED; sessions.destroy_all used to nullify
70
+ # refresh_tokens.session_id (dependent: :nullify), leaving those tokens
71
+ # active after the reset.
72
72
  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)
73
+ StandardId::AccessRevocation.revoke_all!(account, reason: "password_reset")
75
74
  end
76
75
  end
77
76
  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) %>
@@ -20,3 +20,11 @@ en:
20
20
  other: "This code will expire in %{count} minutes."
21
21
  ignore: "If you did not request this code, you can safely ignore this email."
22
22
  footer: "This is an automated message. Please do not reply."
23
+ account_notice_mailer:
24
+ existing_account:
25
+ subject: "Someone tried to sign up with your email"
26
+ greeting: "Hi,"
27
+ intro: "Someone just tried to create an account with this email address, but you already have one."
28
+ action: "If that was you, sign in the way you did before, or reset your password if you have forgotten it."
29
+ ignore: "If it wasn't you, you can safely ignore this email. Your account has not changed."
30
+ footer: "This is an automated message. Please do not reply."
data/config/routes/api.rb CHANGED
@@ -10,6 +10,20 @@ StandardId::ApiEngine.routes.draw do
10
10
  post :start
11
11
  end
12
12
 
13
+ # Email + password for JSON clients (StandardId::EmailPassword). Drawn
14
+ # always but matched only while c.email_password.enabled is true, so the
15
+ # endpoints 404 otherwise.
16
+ constraints(->(_request) { StandardId::EmailPassword.enabled? }) do
17
+ scope "auth", controller: :email_password, as: :email_password do
18
+ post "register", action: :register, as: :register
19
+ post "login", action: :login, as: :login
20
+ post "verify-email", action: :verify, as: :verify
21
+ post "verify-email/resend", action: :resend, as: :resend
22
+ post "password/forgot", action: :forgot_password, as: :forgot_password
23
+ post "password/reset", action: :reset_password, as: :reset_password
24
+ end
25
+ end
26
+
13
27
  namespace :oidc do
14
28
  resource :logout, only: [:show], controller: :logout
15
29
  end