From 8bb151fdc933e97f4eb327f1729e1715f5ec3e66 Mon Sep 17 00:00:00 2001 From: Howard Huang Date: Fri, 28 Aug 2026 23:14:22 -0500 Subject: [PATCH] FEATURE: Add stateless passwordless authentication Replace phone OTP and application Token APIs with validation-only Email Magic Link, Apple, Google, and Passkey flows that return Authentication Evidence. Keep identity, persistence, replay claims, credentials, sessions, and authorization under Host Application control. Update the Rails Engine, generated templates, documentation, and tests for the breaking 0.2.0 contract. --- CONTEXT.md | 116 +++--- Gemfile | 1 + Gemfile.lock | 97 +++-- README.md | 337 ++++-------------- .../auth/email_magic_links_controller.rb | 76 ++++ .../federated_authentications_controller.rb | 72 ++++ .../passkey_authentications_controller.rb | 55 +++ .../auth/passkey_registrations_controller.rb | 49 +++ .../bitsmithy/auth/sessions_controller.rb | 57 --- .../bitsmithy/auth/magic_link_mailer.rb | 17 + .../auth/magic_link_mailer/entry.html.erb | 4 + .../auth/magic_link_mailer/entry.text.erb | 6 + bitsmithy-auth.gemspec | 11 +- config/locales/en.yml | 10 +- config/routes.rb | 19 +- .../0001-stateless-jwt-without-revocation.md | 4 + docs/adr/0002-test-mode-rails-env-guard.md | 4 + ...-verification-primitive-not-user-system.md | 4 + docs/adr/0004-redact-phones-in-exceptions.md | 4 + docs/adr/0005-twilio-verify-as-otp-backend.md | 4 + ...untable-engine-drives-verification-flow.md | 4 + docs/adr/0008-engine-ships-no-views.md | 11 +- .../0009-stateless-authentication-evidence.md | 9 + lib/bitsmithy/auth.rb | 74 ++-- lib/bitsmithy/auth/apple.rb | 27 ++ lib/bitsmithy/auth/apple/authorization.rb | 87 +++++ lib/bitsmithy/auth/apple/client_secret.rb | 35 ++ lib/bitsmithy/auth/apple/http_client.rb | 55 +++ lib/bitsmithy/auth/apple/verification.rb | 95 +++++ lib/bitsmithy/auth/authentication_evidence.rb | 60 ++++ .../{identity.rb => authorization_request.rb} | 4 +- lib/bitsmithy/auth/config.rb | 101 ++++-- lib/bitsmithy/auth/controller.rb | 54 --- lib/bitsmithy/auth/email.rb | 18 + lib/bitsmithy/auth/envelope.rb | 86 +++++ lib/bitsmithy/auth/errors.rb | 7 +- lib/bitsmithy/auth/google.rb | 26 ++ lib/bitsmithy/auth/google/authorization.rb | 81 +++++ lib/bitsmithy/auth/google/http_client.rb | 55 +++ lib/bitsmithy/auth/google/verification.rb | 89 +++++ lib/bitsmithy/auth/magic_link.rb | 82 +++++ lib/bitsmithy/auth/otp/test_adapter.rb | 30 -- lib/bitsmithy/auth/otp/twilio_adapter.rb | 67 ---- lib/bitsmithy/auth/passkey.rb | 50 +++ lib/bitsmithy/auth/passkey/authentication.rb | 87 +++++ lib/bitsmithy/auth/passkey/registration.rb | 75 ++++ lib/bitsmithy/auth/passkey_methods.rb | 38 ++ lib/bitsmithy/auth/phone.rb | 31 -- lib/bitsmithy/auth/rate_limiter.rb | 19 - lib/bitsmithy/auth/result.rb | 10 +- lib/bitsmithy/auth/stores/memory_store.rb | 24 -- .../auth/stores/rails_cache_store.rb | 25 -- lib/bitsmithy/auth/testing.rb | 66 ++++ lib/bitsmithy/auth/token.rb | 31 -- lib/bitsmithy/auth/version.rb | 2 +- .../bitsmithy/auth/install_generator.rb | 8 +- .../bitsmithy/auth/templates/edit.html.erb | 12 - .../email_magic_links/error.html.erb | 5 + .../email_magic_links/exchange.html.erb | 17 + .../templates/email_magic_links/new.html.erb | 9 + .../templates/email_magic_links/sent.html.erb | 4 + .../federated_authentications/new.html.erb | 6 + .../auth/templates/initializer.rb.erb | 40 ++- .../bitsmithy/auth/templates/new.html.erb | 10 - .../bitsmithy/auth/otp/test_twilio_adapter.rb | 130 ------- .../auth/test_apple_authentication.rb | 113 ++++++ .../auth/test_apple_client_secret.rb | 29 ++ test/bitsmithy/auth/test_controller.rb | 78 ---- test/bitsmithy/auth/test_email_magic_link.rb | 72 ++++ .../auth/test_google_authentication.rb | 104 ++++++ .../auth/test_passkey_authentication.rb | 73 ++++ .../auth/test_passkey_registration.rb | 82 +++++ test/bitsmithy/auth/test_rate_limiting.rb | 74 ---- .../bitsmithy/auth/test_stateless_contract.rb | 16 + test/bitsmithy/auth/test_test_mode_guard.rb | 53 --- test/bitsmithy/auth/test_testing_support.rb | 27 ++ test/bitsmithy/auth/test_token_decoding.rb | 59 --- test/bitsmithy/test_auth.rb | 107 +----- test/dummy/app/controllers/test_controller.rb | 9 +- .../auth/email_magic_links/error.html.erb | 2 + .../auth/email_magic_links/exchange.html.erb | 5 + .../auth/email_magic_links/new.html.erb | 5 + .../auth/email_magic_links/sent.html.erb | 1 + .../federated_authentications/error.html.erb | 2 + .../federated_authentications/new.html.erb | 4 + .../bitsmithy/auth/sessions/edit.html.erb | 8 - .../bitsmithy/auth/sessions/new.html.erb | 8 - test/dummy/config/application.rb | 3 + test/dummy/config/routes.rb | 1 + test/engine/test_apple_authentication_flow.rb | 66 ++++ test/engine/test_email_magic_link_flow.rb | 88 +++++ .../engine/test_email_magic_link_generator.rb | 34 ++ .../engine/test_google_authentication_flow.rb | 65 ++++ test/engine/test_install_generator.rb | 113 ++---- test/engine/test_locale_smoke.rb | 6 +- .../test_passkey_authentication_flow.rb | 76 ++++ test/engine/test_passkey_registration_flow.rb | 61 ++++ test/engine/test_rails_cache_store.rb | 27 -- test/engine/test_sessions_flow.rb | 167 --------- test/support/config_helper.rb | 7 - test/test_helper.rb | 7 - 101 files changed, 2823 insertions(+), 1632 deletions(-) create mode 100644 app/controllers/bitsmithy/auth/email_magic_links_controller.rb create mode 100644 app/controllers/bitsmithy/auth/federated_authentications_controller.rb create mode 100644 app/controllers/bitsmithy/auth/passkey_authentications_controller.rb create mode 100644 app/controllers/bitsmithy/auth/passkey_registrations_controller.rb delete mode 100644 app/controllers/bitsmithy/auth/sessions_controller.rb create mode 100644 app/mailers/bitsmithy/auth/magic_link_mailer.rb create mode 100644 app/views/bitsmithy/auth/magic_link_mailer/entry.html.erb create mode 100644 app/views/bitsmithy/auth/magic_link_mailer/entry.text.erb create mode 100644 docs/adr/0009-stateless-authentication-evidence.md create mode 100644 lib/bitsmithy/auth/apple.rb create mode 100644 lib/bitsmithy/auth/apple/authorization.rb create mode 100644 lib/bitsmithy/auth/apple/client_secret.rb create mode 100644 lib/bitsmithy/auth/apple/http_client.rb create mode 100644 lib/bitsmithy/auth/apple/verification.rb create mode 100644 lib/bitsmithy/auth/authentication_evidence.rb rename lib/bitsmithy/auth/{identity.rb => authorization_request.rb} (50%) delete mode 100644 lib/bitsmithy/auth/controller.rb create mode 100644 lib/bitsmithy/auth/email.rb create mode 100644 lib/bitsmithy/auth/envelope.rb create mode 100644 lib/bitsmithy/auth/google.rb create mode 100644 lib/bitsmithy/auth/google/authorization.rb create mode 100644 lib/bitsmithy/auth/google/http_client.rb create mode 100644 lib/bitsmithy/auth/google/verification.rb create mode 100644 lib/bitsmithy/auth/magic_link.rb delete mode 100644 lib/bitsmithy/auth/otp/test_adapter.rb delete mode 100644 lib/bitsmithy/auth/otp/twilio_adapter.rb create mode 100644 lib/bitsmithy/auth/passkey.rb create mode 100644 lib/bitsmithy/auth/passkey/authentication.rb create mode 100644 lib/bitsmithy/auth/passkey/registration.rb create mode 100644 lib/bitsmithy/auth/passkey_methods.rb delete mode 100644 lib/bitsmithy/auth/phone.rb delete mode 100644 lib/bitsmithy/auth/rate_limiter.rb delete mode 100644 lib/bitsmithy/auth/stores/memory_store.rb delete mode 100644 lib/bitsmithy/auth/stores/rails_cache_store.rb create mode 100644 lib/bitsmithy/auth/testing.rb delete mode 100644 lib/bitsmithy/auth/token.rb delete mode 100644 lib/generators/bitsmithy/auth/templates/edit.html.erb create mode 100644 lib/generators/bitsmithy/auth/templates/email_magic_links/error.html.erb create mode 100644 lib/generators/bitsmithy/auth/templates/email_magic_links/exchange.html.erb create mode 100644 lib/generators/bitsmithy/auth/templates/email_magic_links/new.html.erb create mode 100644 lib/generators/bitsmithy/auth/templates/email_magic_links/sent.html.erb create mode 100644 lib/generators/bitsmithy/auth/templates/federated_authentications/new.html.erb delete mode 100644 lib/generators/bitsmithy/auth/templates/new.html.erb delete mode 100644 test/bitsmithy/auth/otp/test_twilio_adapter.rb create mode 100644 test/bitsmithy/auth/test_apple_authentication.rb create mode 100644 test/bitsmithy/auth/test_apple_client_secret.rb delete mode 100644 test/bitsmithy/auth/test_controller.rb create mode 100644 test/bitsmithy/auth/test_email_magic_link.rb create mode 100644 test/bitsmithy/auth/test_google_authentication.rb create mode 100644 test/bitsmithy/auth/test_passkey_authentication.rb create mode 100644 test/bitsmithy/auth/test_passkey_registration.rb delete mode 100644 test/bitsmithy/auth/test_rate_limiting.rb create mode 100644 test/bitsmithy/auth/test_stateless_contract.rb delete mode 100644 test/bitsmithy/auth/test_test_mode_guard.rb create mode 100644 test/bitsmithy/auth/test_testing_support.rb delete mode 100644 test/bitsmithy/auth/test_token_decoding.rb create mode 100644 test/dummy/app/views/bitsmithy/auth/email_magic_links/error.html.erb create mode 100644 test/dummy/app/views/bitsmithy/auth/email_magic_links/exchange.html.erb create mode 100644 test/dummy/app/views/bitsmithy/auth/email_magic_links/new.html.erb create mode 100644 test/dummy/app/views/bitsmithy/auth/email_magic_links/sent.html.erb create mode 100644 test/dummy/app/views/bitsmithy/auth/federated_authentications/error.html.erb create mode 100644 test/dummy/app/views/bitsmithy/auth/federated_authentications/new.html.erb delete mode 100644 test/dummy/app/views/bitsmithy/auth/sessions/edit.html.erb delete mode 100644 test/dummy/app/views/bitsmithy/auth/sessions/new.html.erb create mode 100644 test/engine/test_apple_authentication_flow.rb create mode 100644 test/engine/test_email_magic_link_flow.rb create mode 100644 test/engine/test_email_magic_link_generator.rb create mode 100644 test/engine/test_google_authentication_flow.rb create mode 100644 test/engine/test_passkey_authentication_flow.rb create mode 100644 test/engine/test_passkey_registration_flow.rb delete mode 100644 test/engine/test_rails_cache_store.rb delete mode 100644 test/engine/test_sessions_flow.rb diff --git a/CONTEXT.md b/CONTEXT.md index 26a0a66..eecd1fd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,72 +1,74 @@ -# bitsmithy-auth +# RubyAuth -A Ruby gem that verifies a phone number via SMS OTP and returns a signed token. It is a verification primitive, not a user management system — host apps map verified phones to their own user models. +RubyAuth is a stateless Ruby authentication library that validates Apple, Google, Email Magic Link, and Passkey credentials. +It returns Authentication Evidence while each Host Application owns identity, persistence, sessions, authorization, and lifecycle. ## Language -**Host app**: -The Ruby application that depends on this gem to verify users. -_Avoid_: client, consumer, parent app. +**Host Application**: +A Ruby application that uses RubyAuth to validate a Sign-in Method and decides what the successful evidence means. +The Host Application supplies configuration and every durable or temporary state operation. +_Avoid_: Client, consumer, parent app -**Identity**: -The verification artifact returned by `decode_token`. Carries a phone, the time it was issued, and the time it expires. It is NOT a user — host apps map an Identity's phone to their own user records. -_Avoid_: User, Account, Session, Principal. +**Sign-in Method**: +A way to authenticate through Apple, Google, a Verified Email, or a Passkey. +RubyAuth validates a Sign-in Method but never attaches it to an application user. +_Avoid_: User, account, session -**Phone**: -A phone number normalised to E.164 format. The only identifier this gem knows about. -_Avoid_: number, msisdn, telephone, mobile. - -**Verification**: -The act of confirming someone controls a phone — sending an OTP, then checking the entered code. Comprises a send step and a verify step. -_Avoid_: authentication, login, sign-in. - -**OTP**: -A short numeric code (six digits) sent to a phone via SMS. Generated, expired, and attempt-limited by Twilio Verify, not by this gem. -_Avoid_: code (when ambiguous), pin, password. - -**Token**: -The signed JWT issued on successful verification. Carries the verified phone as the `sub` claim, plus `iat`, `exp`, and `iss: "bitsmithy-auth"`. The host app stores it (typically in `session[]`) and presents it back on subsequent requests, where `decode_token` turns it into an Identity. -_Avoid_: JWT (use only when discussing the wire format specifically), session, cookie, credential. +**Authentication Evidence**: +The immutable successful Result that identifies the validated Sign-in Method, authentication time, and method-specific verified values. +Authentication Evidence never identifies an application user and never grants application authorization by itself. +_Avoid_: User, application session, application Token **Result**: -The value object returned by `send_code` and `verify_code` — carries `success?`, `error` (symbol), `token`, `channel`, and `phone`. Used in place of exceptions for expected failure modes (wrong code, rate-limited, invalid phone). -_Avoid_: response, outcome, status. - -**OTP adapter**: -The strategy that sends and verifies codes. `TwilioAdapter` (production) wraps Twilio Verify; `TestAdapter` (test mode) skips the network and accepts the magic code `"000000"`. -_Avoid_: provider, backend, gateway. - -**Verify Service**: -A Twilio-side configuration unit referenced by SID. One per host app at minimum; each carries Twilio-side rate limits, SMS templates, and per-channel settings. The gem references one configured via `twilio_verify_service_sid`. -_Avoid_: service (too vague), verification service. +The value returned by a RubyAuth finish or validation operation. +It contains either Authentication Evidence or a stable safe failure symbol and optional safe metadata. +_Avoid_: Provider response, exception payload + +**Verified Email**: +An email address whose control a trusted provider or Email Magic Link proved during authentication. +RubyAuth trims surrounding whitespace and case-folds the address without removing dots, plus suffixes, or provider-specific aliases. +_Avoid_: Unverified email, provider profile + +**Email Magic Link**: +A ten-minute encrypted credential sent to a Verified Email and validated by RubyAuth after a browser posts it from the URL fragment. +RubyAuth returns a replay identifier, and the Host Application decides atomically whether that identifier can be used once. +_Avoid_: Password reset link, reusable link + +**Provider Subject**: +The stable identifier that Apple or Google asserts for one provider identity. +A provider can omit the Verified Email on later authentication while continuing to assert the same Provider Subject. +_Avoid_: Email, application user ID + +**Passkey**: +A discoverable WebAuthn credential that requires local user verification without disclosing a biometric to RubyAuth. +The Host Application stores its public credential values and supplies them to RubyAuth for validation. +_Avoid_: Password, biometric identity + +**Ceremony Envelope**: +A short-lived authenticated encrypted value that carries OAuth or Passkey challenge state through the browser. +RubyAuth issues and validates the envelope without retaining server-side ceremony state. +_Avoid_: Database session, persistent challenge + +**Rails Engine**: +The optional mountable Rails flow that owns authentication routes, validates external input, resets the browser session after successful authentication, and invokes explicit Host Application callbacks. +It never decides which application user the Authentication Evidence represents. +_Avoid_: User management engine, session store -**Test mode**: -A configuration in which the OTP adapter is swapped to `TestAdapter`. Sends always succeed; `"000000"` always verifies. Intended for host-app test suites — never production. -_Avoid_: stub mode, fake mode, mock mode. - -**Signing key**: -The HMAC-SHA256 secret used to sign and verify Tokens. Configured via `signing_key`. Must be a high-entropy random string (≥ 32 bytes recommended). -_Avoid_: secret, key, JWT secret. - -**Rate limiter**: -The pre-send gate that limits `send_code` attempts per Phone per window. Independent of Twilio Verify's own per-service limits. -_Avoid_: throttle, gate. +## Example dialogue -**Engine**: -The mountable Rails engine that drives the **Verification flow** end-to-end — it owns the routes and controller, manages the pending-**Phone** session state, and issues the **Token**. It produces an **Identity** and nothing more: it never touches a host app's user records, and it ships no views. Mounted in a single line; all meaning is delegated to the **Host app**. -_Avoid_: app, plugin, mountable app, sign-in engine. +**Developer**: Google returned an email and a subject. +Which one is my User? -**Verification flow**: -The host-facing sequence the **Engine** drives: enter **Phone** → receive **OTP** → enter code → **Token** issued and stored in `session[]`. Comprises a send step and a verify step, each rendering a host-owned template. On success the **Host app** is redirected to its configured landing path; on an expected failure the same step re-renders with an error. -_Avoid_: login flow, sign-in flow, auth flow, wizard. +**Domain expert**: Neither. +RubyAuth returns Authentication Evidence with the Google Provider Subject and Verified Email, and your Host Application resolves them to its own user. -## Example dialogue +**Developer**: Where does RubyAuth store used Email Magic Links? -> **Dev:** When a phone is verified, do we mark the User active? -> **Domain expert:** Wrong direction. This gem doesn't know what a User is. You just got back an Identity carrying the verified Phone. *Your* code decides what that means — for one host app it's `User.find_or_create_by(phone:)`; for another it's looking up an existing admin and rejecting unknown phones. +**Domain expert**: Nowhere. +RubyAuth validates the encrypted credential and returns its replay identifier, and your Host Application claims that identifier atomically. -> **Dev:** Can the Identity carry an email? -> **Domain expert:** No — the gem only does Phone. If you also want email, that's a different verification flow; the Identity stays phone-only. +**Developer**: Does RubyAuth save a Passkey public key? -> **Dev:** I want to test the sign-in form without hitting Twilio in CI. -> **Domain expert:** Use Test mode. `Bitsmithy::Auth.test_mode!` swaps the OTP adapter — `send_code` succeeds and `verify_code` accepts `"000000"`. Don't ship that to production. +**Domain expert**: No. +Your Host Application loads and stores Passkey values, while RubyAuth performs the WebAuthn ceremony and cryptographic validation. diff --git a/Gemfile b/Gemfile index b30a5df..38e9afc 100644 --- a/Gemfile +++ b/Gemfile @@ -18,6 +18,7 @@ gem "rubocop-rake", require: false gem "rubocop-rspec", require: false group :test do + gem "actionmailer", "~> 8.0" gem "actionpack", "~> 8.0" gem "cgi" gem "railties", "~> 8.0" diff --git a/Gemfile.lock b/Gemfile.lock index a8203c7..7c5fa20 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,14 +1,20 @@ PATH remote: . specs: - bitsmithy-auth (0.1.0) + bitsmithy-auth (0.2.0) jwt (~> 3.2) - phonelib (~> 0.10) - twilio-ruby (~> 7.0) + webauthn (~> 3.4) GEM remote: https://rubygems.org/ specs: + actionmailer (8.1.3) + actionpack (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activesupport (= 8.1.3) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) actionpack (8.1.3) actionview (= 8.1.3) activesupport (= 8.1.3) @@ -25,6 +31,9 @@ GEM erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) + activejob (8.1.3) + activesupport (= 8.1.3) + globalid (>= 0.3.6) activesupport (8.1.3) base64 bigdecimal @@ -38,24 +47,26 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) + android_key_attestation (0.3.0) ast (2.4.3) base64 (0.3.0) bigdecimal (4.1.2) + bindata (2.5.1) builder (3.3.0) + cbor (0.5.10.3) cgi (0.5.1) concurrent-ruby (1.3.6) connection_pool (3.0.2) + cose (1.3.1) + cbor (~> 0.5.9) + openssl-signature_algorithm (~> 1.0) crass (1.0.6) date (3.5.1) drb (2.2.3) erb (6.0.4) erubi (1.13.1) - faraday (2.14.2) - faraday-net_http (>= 2.0, < 3.5) - json - logger - faraday-net_http (3.4.3) - net-http (~> 0.5) + globalid (1.4.0) + activesupport (>= 6.1) i18n (1.14.8) concurrent-ruby (~> 1.0) io-console (0.8.2) @@ -73,18 +84,34 @@ GEM loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) + mail (2.9.1) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + mini_mime (1.1.5) minitest (5.27.0) mocha (2.8.2) ruby2_keywords (>= 0.0.5) - net-http (0.9.1) - uri (>= 0.11.1) + net-imap (0.6.6) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.3.0) + timeout + net-smtp (0.5.1) + net-protocol nokogiri (1.19.3-x86_64-linux-gnu) racc (~> 1.4) + openssl (4.0.2) + openssl-signature_algorithm (1.3.0) + openssl (> 2.0) parallel (2.1.0) parser (3.3.11.1) ast (~> 2.4.1) racc - phonelib (0.10.20) pp (0.6.3) prettyprint prettyprint (0.2.0) @@ -153,14 +180,17 @@ GEM rubocop (~> 1.86, >= 1.86.2) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) + safety_net_attestation (0.5.0) + jwt (>= 2.0, < 4.0) securerandom (0.4.1) stringio (3.2.0) thor (1.5.0) + timeout (0.6.1) + tpm-key_attestation (0.14.2) + bindata (~> 2.4) + openssl (> 2.0) + openssl-signature_algorithm (~> 1.0) tsort (0.2.0) - twilio-ruby (7.10.7) - faraday (>= 2.0, < 3.0) - jwt (>= 1.5, < 4.0) - nokogiri (>= 1.6, < 2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) @@ -169,12 +199,21 @@ GEM uri (1.1.1) useragent (0.16.11) warning (1.6.0) + webauthn (3.4.3) + android_key_attestation (~> 0.3.0) + bindata (~> 2.4) + cbor (~> 0.5.9) + cose (~> 1.1) + openssl (>= 2.2) + safety_net_attestation (~> 0.5.0) + tpm-key_attestation (~> 0.14.0) zeitwerk (2.8.2) PLATFORMS x86_64-linux DEPENDENCIES + actionmailer (~> 8.0) actionpack (~> 8.0) bitsmithy-auth! cgi @@ -190,25 +229,30 @@ DEPENDENCIES warning (~> 1.5) CHECKSUMS + actionmailer (8.1.3) sha256=831f724891bb70d0aaa4d76581a6321124b6a752cb655c9346aae5479318448d actionpack (8.1.3) sha256=af998cae4d47c5d581a2cc363b5c77eb718b7c4b45748d81b1887b25621c29a3 actionview (8.1.3) sha256=1347c88c7f3edb38100c5ce0e9fb5e62d7755f3edc1b61cce2eb0b2c6ea2fd5d + activejob (8.1.3) sha256=a149b1766aa8204c3c3da7309e4becd40fcd5529c348cffbf6c9b16b565fe8d3 activesupport (8.1.3) sha256=21a5e0dfbd4c3ddd9e1317ec6a4d782fa226e7867dc70b0743acda81a1dca20e + android_key_attestation (0.3.0) sha256=467eb01a99d2bb48ef9cf24cc13712669d7056cba5a52d009554ff037560570b ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd - bitsmithy-auth (0.1.0) + bindata (2.5.1) sha256=53186a1ec2da943d4cb413583d680644eb810aacbf8902497aac8f191fad9e58 + bitsmithy-auth (0.2.0) builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f bundler (4.0.12) sha256=7f8b757d28dfb636e7b24fba2344ac6dd13b5b24f4b46d62573d483f211825ac + cbor (0.5.10.3) sha256=c3aa1d0c7e9bbffe8de4bed554f588d7926c62276ffe2dd7fabb65bae801d28a cgi (0.5.1) sha256=e93fcafc69b8a934fe1e6146121fa35430efa8b4a4047c4893764067036f18e9 concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + cose (1.3.1) sha256=d5d4dbcd6b035d513edc4e1ab9bc10e9ce13b4011c96e3d1b8fe5e6413fd6de5 crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 - faraday (2.14.2) sha256=73ccb9994a9e8648f010e32eca2ae82e41c57860aa10932cda29418b9e0223ad - faraday-net_http (3.4.3) sha256=9db13becec9312f345a769eeeecf9049c9287d54c0ae053d7235228993a4eec1 + globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 @@ -218,13 +262,19 @@ CHECKSUMS lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 + mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5 mocha (2.8.2) sha256=1f77e729db47e72b4ef776461ce20caeec2572ffdf23365b0a03608fee8f4eee - net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 + net-imap (0.6.6) sha256=96aa4ee50df3060203e649efc341f53480b791d49e150f2fdebf68beb141a8df + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.3.0) sha256=ba310c3d4f1cad46bb1ab20336b06669b1ff8f7c568d9cb9342b32a718547472 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 nokogiri (1.19.3-x86_64-linux-gnu) sha256=2f5078620fe12e83669b5b17311b32532a8153d02eee7ad06948b926d6080976 + openssl (4.0.2) sha256=1037ad2868ae58df9ad917891c0c0f9815a1172f6846d4bcdd508e4c2ee747c2 + openssl-signature_algorithm (1.3.0) sha256=a3b40b5e8276162d4a6e50c7c97cdaf1446f9b2c3946a6fa2c14628e0c957e80 parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 - phonelib (0.10.20) sha256=15af54cddbec5a73ee2ca466f9ccc2dd849f58d1c434c718df096601f8c6dbfe pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 @@ -249,17 +299,20 @@ CHECKSUMS rubocop-rspec (3.10.2) sha256=0b3e2ecc592cd10ecbf0095bb58d1e357905276e069643523cc19eb7495f65e2 ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef + safety_net_attestation (0.5.0) sha256=c8cd01dd550dbe8553862918af6355a04672db11d218ec96104ce3955293f2aa securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tpm-key_attestation (0.14.2) sha256=3c994239f643822efe67a9cfbbfc357d0cd037a8662d5abb45d4dd79dce0e93c tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f - twilio-ruby (7.10.7) sha256=1551b05c221eafe678e63e6776ac46211a13e6742d865c53d124af24aabed6a9 tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 warning (1.6.0) sha256=a49cdfae19fb77d19afff2efbe45f8ab759e9cd25b4e4ce2c79dbaf46bdb6c9e + webauthn (3.4.3) sha256=9be6f5f838f3405b0226e560aa40b67cc8c15ec9154509b997caa7ec9a05e1fc zeitwerk (2.8.2) sha256=7212a61311083c604184b1ea2574b9aa05cd14f855a0841c06985cabe9181d12 BUNDLED WITH diff --git a/README.md b/README.md index 8446155..1902f25 100644 --- a/README.md +++ b/README.md @@ -1,300 +1,105 @@ -# bitsmithy-auth +# RubyAuth -Phone-number OTP authentication for Ruby applications. Verify a user by sending them an SMS code; receive a signed Token in return; decode it into an Identity. +Stateless passwordless authentication for Ruby applications. -This gem is a **verification primitive**, not a user-management system. It does not own a User model, a sessions table, or a phone-change flow. Host apps map verified Phones to their own user records however they want. See [`docs/adr/0003`](docs/adr/0003-verification-primitive-not-user-system.md) for the rationale. +RubyAuth validates Apple, Google, Email Magic Link, and Passkey credentials. +It returns **Authentication Evidence** and never owns an application user, database, persistent cache, application session, or application Token. +The Host Application resolves the evidence to its user and owns every lifecycle decision. -Backed by [Twilio Verify](https://www.twilio.com/docs/verify) — Twilio owns OTP generation, expiry, attempt limits, and SMS-pumping fraud detection. We never store an OTP locally. +## Public contract -## Status — v0.1.0 +Every finish operation returns a `Bitsmithy::Auth::Result`. +A successful Result contains `evidence` and a failed Result contains a stable `error` symbol. -| Shipped | -|---| -| Framework-agnostic core API | -| Rails `Controller` concern (optional) | -| Twilio Verify production adapter | -| Per-Phone rate limiting (in-memory store) | -| PII-redacting `redact_phone` helper | -| `test_mode!` with Rails-env guard | -| Mountable Rails engine (no shipped views) | -| Opt-in `require_authentication!` guard | -| Shipped `en` locale for error messages | +Authentication Evidence contains: -### Future (not yet shipped) +- `sign_in_method` +- `authenticated_at` +- `email` when verified and supplied +- `provider` and `subject` for Apple or Google +- `credential_id`, `user_handle`, and `signature_count` for a Passkey +- `replay_id` for an Email Magic Link -Install generator, Redis-backed rate-limit store, voice / WhatsApp channels, email OTP / TOTP. +RubyAuth does not convert these values into an application user. -## Installation +## Host-owned state -This is a private gem distributed via the `bitsmithy/auth-ruby` repo on GitHub. +The Host Application owns: -```ruby -# Gemfile -gem "bitsmithy-auth", github: "bitsmithy/auth-ruby" -``` +- Identity resolution and account linking. +- Email request rate limits. +- Atomic Email Magic Link replay claims. +- Passkey credential storage and counter updates. +- Browser and API sessions. +- Authorization and post-authentication routing. +- Provider credentials, encryption keys, sender details, and relying-party configuration. -Generate a signing key once per environment and store it in your secrets manager: +## Email Magic Links -```bash -$ ruby -rsecurerandom -e 'puts SecureRandom.hex(32)' -``` +`request_email_magic_link` normalizes the email, creates a ten-minute authenticated encrypted credential, and invokes the configured Action Mailer delivery. +The generated link carries the credential in its URL fragment so the initial GET cannot authenticate or consume it. +The generated exchange template posts the fragment credential with the Rails CSRF token. -## Configuration - -```ruby -# config/initializers/bitsmithy_auth.rb -require "bitsmithy/auth" -require "bitsmithy/auth/otp/twilio_adapter" # only when using Twilio in production - -Bitsmithy::Auth.configure do |c| - c.signing_key = ENV.fetch("BITSMITHY_AUTH_SIGNING_KEY") - c.twilio_account_sid = ENV.fetch("TWILIO_ACCOUNT_SID") - c.twilio_auth_token = ENV.fetch("TWILIO_AUTH_TOKEN") - c.twilio_verify_service_sid = ENV.fetch("TWILIO_VERIFY_SERVICE_SID") - c.otp_adapter = Bitsmithy::Auth::OTP::TwilioAdapter.new(c) - - # Optional overrides (defaults shown): - # c.session_duration = 86_400 # 24h - # c.rate_limit = { per_phone: 5, window: 3_600 } # 5/hr/phone -end -``` - -The four required fields above are validated at configure time: - -```ruby -Bitsmithy::Auth.config.validate! # raises ConfigurationError if any is unset -``` - -## Core API - -Five module methods on `Bitsmithy::Auth`. The convention: expected user-input failures return a `Result`; programmer-error or tampering failures raise. - -| Method | Returns | Failure mode | -|---|---|---| -| `send_code(phone)` | `Result` | returns failure Result; never raises | -| `verify_code(phone, code)` | `Result` (with `.token` on success) | returns failure Result; never raises | -| `decode_token(token)` | `Identity` | raises `InvalidToken` | -| `normalize_phone(input, country:)` | `String` (E.164) | raises `InvalidPhoneNumber` | -| `redact_phone(phone)` | `String` (masked) | does not fail | - -### `Result` shape - -```ruby -result = Bitsmithy::Auth.verify_code("+12127363100", "000000") -result.success? # => true / false -result.error # => symbol or nil — see "Error vocabulary" below -result.token # => JWT string (success only) or nil -result.phone # => E.164 String -result.channel # => :sms -``` - -### `Identity` shape - -```ruby -identity = Bitsmithy::Auth.decode_token(token) -identity.phone # => "+12127363100" -identity.issued_at # => Time -identity.expires_at # => Time -``` - -## Rails Controller concern - -Loaded automatically when `ActionController` is defined. Include it in your `ApplicationController`: - -```ruby -class ApplicationController < ActionController::Base - include Bitsmithy::Auth::Controller -end -``` - -You get: - -| Method | What it does | -|---|---| -| `current_identity` | Decodes the Token from `session[:bitsmithy_auth_token]`. Memoised per request. Returns `nil` if no token, invalid token, or expired token. | -| `current_phone` | `current_identity&.phone` | -| `authenticated?` | `!current_identity.nil?` | -| `sign_in(token:)` | Writes the Token to session; invalidates the memoised identity | -| `sign_out` | Clears the session key; invalidates the memo | -| `require_authentication!` | Redirects to the configured sign-in path (default the Engine's sign-in route) if not authenticated — see [Engine](#mountable-engine) below. Opt-in per controller: `before_action :require_authentication!` | - -`require_authentication!` is the opt-in guard. Add it to any controller (or your `ApplicationController`) with a single `before_action` — it is never auto-applied. - -## Mountable engine - -When `Rails::Engine` is available (Rails app with `railties`), the gem ships `Bitsmithy::Auth::Engine` — a mountable Rails engine that owns the entire sign-in flow. - -### One-line mount - -```ruby -# config/routes.rb -Rails.application.routes.draw do - mount Bitsmithy::Auth::Engine => "/auth" -end -``` - -This gives you these routes: - -| Method | Path | Engine action | Named helper | -|---|---|---|---| -| GET | `/auth/sign_in` | `sessions#new` | `sign_in_path` | -| POST | `/auth/send_code` | `sessions#create` | `send_code_path` | -| GET | `/auth/code` | `sessions#edit` | `code_path` | -| POST | `/auth/verify` | `sessions#update` | `verify_path` | -| DELETE | `/auth/sign_out` | `sessions#destroy` | `sign_out_path` | - -### Required templates - -The engine **ships no views** (ADR-0008). Your app must provide two templates: - -**`app/views/bitsmithy/auth/sessions/new.html.erb`** — Phone-entry form. +`verify_email_magic_link` returns Verified Email Authentication Evidence and a replay identifier. +The Host Application must claim that identifier atomically before creating its session. -| Local / helper | Description | -|---|---| -| `@error` | Error message string when re-rendered after a failure (nil on first load) | -| `send_code_path` | Named route helper for the send step (POST) | +## Apple and Google -**`app/views/bitsmithy/auth/sessions/edit.html.erb`** — Code-entry form. +`start_apple_authentication` and `start_google_authentication` produce authorization URLs with encrypted state, nonce, PKCE, a safe return path, and a ten-minute expiry. +The matching finish operations validate signature, issuer, audience, state, nonce, PKCE exchange inputs, provider time claims, stable subject, and verified email when supplied. -| Local / helper | Description | -|---|---| -| `@phone` | The pending Phone (E.164 string) stored from the send step | -| `@error` | Error message string when re-rendered after a failure (nil on first load) | -| `verify_path` | Named route helper for the verify step (POST) | +Apple private relay addresses are returned as ordinary normalized Verified Emails. +Apple can omit email after first authorization, so Host Applications must persist the first successful provider mapping. -### Configuration +Provider HTTP clients use bounded connection and response timeouts. +Public provider signing keys can be cached briefly in memory and are never authoritative user state. -Configure the engine in the same initializer you already use: +## Passkeys -```ruby -# config/initializers/bitsmithy_auth.rb -Bitsmithy::Auth.configure do |c| - c.signing_key = ENV.fetch("BITSMITHY_AUTH_SIGNING_KEY") - c.twilio_account_sid = ENV.fetch("TWILIO_ACCOUNT_SID") - c.twilio_auth_token = ENV.fetch("TWILIO_AUTH_TOKEN") - c.twilio_verify_service_sid = ENV.fetch("TWILIO_VERIFY_SERVICE_SID") - c.otp_adapter = Bitsmithy::Auth::OTP::TwilioAdapter.new(c) +`start_passkey_registration` produces discoverable WebAuthn registration options and an encrypted Ceremony Envelope. +`finish_passkey_registration` validates origin, relying party, challenge, user handle, local user verification, and the no-identifying-attestation policy before returning persistence-ready public values. - # Optional overrides (defaults shown): - # c.session_duration = 86_400 - # c.after_sign_in_path = "/" - # c.after_sign_out_path = "/" - # c.on_verified = ->(identity) { ... } -end -``` +`start_passkey_authentication` produces discoverable authentication options for conditional browser mediation. +`finish_passkey_authentication` accepts the Host Application's stored public credential values and returns Passkey Authentication Evidence plus a validated signature-counter update. -| Config | Default | Description | -|---|---|---| -| `after_sign_in_path` | `"/"` | Where to redirect after successful verification | -| `after_sign_out_path` | `"/"` | Where to redirect after sign-out | -| `sign_in_path` | Engine's sign-in route (`/auth/sign_in` when mounted at `/auth`) | Redirect target for `require_authentication!` — can be overridden by host | -| `on_verified` | `nil` | Optional callback invoked with the verified Identity on successful verification | +RubyAuth never stores Passkey data or receives a biometric. -### Using `require_authentication!` +## Rails Engine -The engine Controller concern provides an opt-in `require_authentication!` guard. Add it to any controller to protect actions: +Mount `Bitsmithy::Auth::Engine` to use the optional Rails routes for: -```ruby -class ApplicationController < ActionController::Base - include Bitsmithy::Auth::Controller - before_action :require_authentication! -end -``` +- Entry choice. +- Apple and Google start and callbacks. +- Email Magic Link request, sent, exchange, and verification. +- Passkey registration and authentication ceremonies. -Unauthenticated requests are redirected to the configured `sign_in_path` (default: the engine's sign-in route). The engine's own controller skips this guard so the sign-in flow stays accessible. +The Engine renders Host Application templates or generated starter templates. +After successful authentication it resets the browser session, invokes `on_authenticated` with Authentication Evidence and the new session container, and returns to the validated application-relative destination. -### Mapping the verified Identity downstream +The Host Application supplies callbacks for rate limiting, replay claims, identity completion, Passkey authorization, credential lookup, credential storage, and counter updates. -After a successful sign-in, `current_identity` returns the decoded Identity. Map the verified phone to your own user records wherever you need it: - -```ruby -# app/controllers/application_controller.rb -def current_user - return unless current_identity - - @current_user ||= User.find_or_create_by!(phone: current_identity.phone) -end -``` - -The engine never owns a User model — you decide what a verified phone means. - -## Test mode - -```ruby -# test_helper.rb / spec_helper.rb -Bitsmithy::Auth.configure { |c| c.signing_key = "x" * 64 } -Bitsmithy::Auth.test_mode! -``` - -Then in tests, `send_code` always succeeds and `verify_code(phone, "000000")` always issues a decodable Token — no Twilio calls. - -`test_mode!` raises `ConfigurationError` outside `Rails.env.test?` / `Rails.env.development?`, and refuses in non-Rails contexts entirely. See [`docs/adr/0002`](docs/adr/0002-test-mode-rails-env-guard.md) for why this guard is non-negotiable. - -## Error vocabulary - -Error messages for the engine-flow symbols (`invalid_phone_number`, `rate_limited`, `invalid_code`) are shipped in the `en` locale under `bitsmithy_auth.errors.`. Host apps override by defining the same keys in their own locale files. See also the [Mountable engine](#mountable-engine) section. - -`Result#error` is always one of: - -| Symbol | Origin | Meaning | -|---|---|---| -| `:invalid_phone_number` | gem / Twilio 60200 | Phone failed normalisation, or Twilio rejected the format | -| `:invalid_code` | Twilio Verify | User typed the wrong OTP | -| `:rate_limited` | gem | Gem-side rate limiter triggered | -| `:max_send_attempts` | Twilio 60203 | Twilio's per-Phone send cap reached | -| `:max_check_attempts` | Twilio 60202 | Too many verification attempts against one code | -| `:twilio_authentication_error` | Twilio HTTP 401 | Your Twilio credentials are wrong | -| `:twilio_rate_limited` | Twilio HTTP 429 | Twilio is throttling the whole account | -| `:twilio_service_unavailable` | Twilio HTTP 5xx / timeout | Twilio is having an incident | -| `:twilio_error` | fallback | Unmapped Twilio error | - -Exceptions you may catch directly: - -| Class | When | -|---|---| -| `Bitsmithy::Auth::Error` | Base — all gem exceptions inherit | -| `Bitsmithy::Auth::ConfigurationError` | Missing required config; `test_mode!` outside Rails test/dev | -| `Bitsmithy::Auth::InvalidPhoneNumber` | `normalize_phone` couldn't parse | -| `Bitsmithy::Auth::InvalidToken` | `decode_token` failed signature / expiry / issuer | -| `Bitsmithy::Auth::RateLimited` | Internal — converted to `Result.failure(:rate_limited)` before host apps see it | - -## PII redaction - -Phone numbers are PII. The gem ships `Bitsmithy::Auth.redact_phone` for use in your own log statements: - -```ruby -Rails.logger.info("Sent code to #{Bitsmithy::Auth.redact_phone(phone)}") -# => "Sent code to +1******1234" -``` - -The masking rule: keep the leading `+` and country code, mask the middle with `*`, keep the trailing four digits. - -`Result#phone` carries the unredacted normalised Phone — the success path needs it to write to your User table. **Don't log `Result` objects raw** — use `redact_phone(result.phone)` instead. See [`docs/adr/0004`](docs/adr/0004-redact-phones-in-exceptions.md) for the nuance about when redaction applies. - -## Multi-language portfolio - -This gem is the reference implementation of bitsmithy-auth. Sibling implementations follow the same wire contract — Phone (E.164), Token (HS256 JWT with `iss: "bitsmithy-auth"`), error symbols, Identity shape: - -- `bitsmithy/auth-ruby` (this gem) -- `bitsmithy/auth-python` *(planned)* -- `bitsmithy/auth-go` *(planned)* +## Configuration -A Token issued by any of the three validates in any of the three, provided they share `signing_key` and Twilio Verify Service SID. See [`docs/adr/0006`](docs/adr/0006-pattern-a-cross-language-naming.md) for the naming convention. +Configure only the methods that the Host Application enables. +Call `validate!` after assigning the required host callbacks and enabled provider settings. +Configuration accepts values directly and does not prescribe environment variable names. +The install generator provides an environment-variable-based starting template that applications can replace. -## Pointers +## Testing -- [`CONTEXT.md`](CONTEXT.md) — domain glossary (12 terms) -- [`docs/adr/`](docs/adr/) — six Architectural Decision Records -- [`docs/claude/`](docs/claude/) — PRD, tasks, and implementation history +Provider clients and the WebAuthn relying party are injectable through configuration. +This keeps tests deterministic without live Apple, Google, email, or authenticator calls. -## Development +`Bitsmithy::Auth::Testing.authentication_evidence` can create deterministic evidence only when passed the `test` or `development` environment. +It refuses production use. -```bash -bin/setup # bundle install -bundle exec rake # tests + Rubocop -bin/console # IRB with the gem loaded -``` +## Security properties -## License +- Authentication inputs are validated at protocol boundaries. +- OAuth state and Passkey challenges use purpose-bound encrypted envelopes. +- Return destinations must be application-relative. +- Email Magic Link redirects must use absolute HTTPS URLs. +- Expected failures return stable symbols instead of raw provider exceptions. +- Credentials, authorization codes, link fragments, Passkey responses, and full personal data must not be logged. -MIT. See [`LICENSE.txt`](LICENSE.txt). +See `CONTEXT.md` for canonical language and `docs/adr/0009-stateless-authentication-evidence.md` for the architectural boundary. diff --git a/app/controllers/bitsmithy/auth/email_magic_links_controller.rb b/app/controllers/bitsmithy/auth/email_magic_links_controller.rb new file mode 100644 index 0000000..a9de978 --- /dev/null +++ b/app/controllers/bitsmithy/auth/email_magic_links_controller.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Bitsmithy + module Auth + class EmailMagicLinksController < ::ApplicationController + include Concerns::Localization + + def new + render "bitsmithy/auth/email_magic_links/new" + end + + def create + email = Bitsmithy::Auth.normalize_email(params[:email]) + return render_rate_limited unless email_request_allowed?(email) + + render_request_result(request_magic_link(email)) + rescue InvalidEmail + render_failure(Result.failure(error: :invalid_email)) + end + + def exchange + render "bitsmithy/auth/email_magic_links/exchange" + end + + def sent + render "bitsmithy/auth/email_magic_links/sent" + end + + def verify + result = Bitsmithy::Auth.verify_email_magic_link(params[:credential]) + return render_failure(result) unless result.success? + return render_failure(Result.failure(error: :used_magic_link)) unless claim(result.evidence.replay_id) + + complete_authentication(result.evidence) + end + + private + + def claim(replay_id) + Bitsmithy::Auth.config.claim_magic_link.call(replay_id) + end + + def complete_authentication(evidence) + reset_session + Bitsmithy::Auth.config.on_authenticated.call(evidence, session) + redirect_to Bitsmithy::Auth.config.after_authentication_path + end + + def email_request_allowed?(email) + Bitsmithy::Auth.config.allow_email_request.call(email, request) + end + + def render_rate_limited + render_failure(Result.failure(error: :rate_limited), status: :too_many_requests) + end + + def render_request_result(result) + return render_failure(result) unless result.success? + + redirect_to email_magic_link_sent_path + end + + def request_magic_link(email) + Bitsmithy::Auth.request_email_magic_link( + email: email, + redirect_uri: Bitsmithy::Auth.config.magic_link_redirect_uri + ) + end + + def render_failure(result, status: :unprocessable_content) + @error = error_msg(result.error) + render "bitsmithy/auth/email_magic_links/error", status: status + end + end + end +end diff --git a/app/controllers/bitsmithy/auth/federated_authentications_controller.rb b/app/controllers/bitsmithy/auth/federated_authentications_controller.rb new file mode 100644 index 0000000..96f909d --- /dev/null +++ b/app/controllers/bitsmithy/auth/federated_authentications_controller.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Bitsmithy + module Auth + class FederatedAuthenticationsController < ::ApplicationController + include Concerns::Localization + + def new + render "bitsmithy/auth/federated_authentications/new" + end + + def apple + authorization = Bitsmithy::Auth.start_apple_authentication( + redirect_uri: Bitsmithy::Auth.config.apple_redirect_uri, + return_to: params[:return_to].presence || "/" + ) + redirect_to authorization.url, allow_other_host: true + end + + def apple_callback + result = apple_result + return render_failure(result) unless result.success? + + complete_authentication(result) + end + + def google + authorization = Bitsmithy::Auth.start_google_authentication( + redirect_uri: Bitsmithy::Auth.config.google_redirect_uri, + return_to: params[:return_to].presence || "/" + ) + redirect_to authorization.url, allow_other_host: true + end + + def google_callback + result = google_result + return render_failure(result) unless result.success? + + complete_authentication(result) + end + + private + + def apple_result + Bitsmithy::Auth.finish_apple_authentication( + code: params[:code], + state: params[:state], + redirect_uri: Bitsmithy::Auth.config.apple_redirect_uri + ) + end + + def complete_authentication(result) + reset_session + Bitsmithy::Auth.config.on_authenticated.call(result.evidence, session) + redirect_to result.metadata.fetch(:return_to) + end + + def google_result + Bitsmithy::Auth.finish_google_authentication( + code: params[:code], + state: params[:state], + redirect_uri: Bitsmithy::Auth.config.google_redirect_uri + ) + end + + def render_failure(result) + @error = error_msg(result.error) + render "bitsmithy/auth/federated_authentications/error", status: :unprocessable_content + end + end + end +end diff --git a/app/controllers/bitsmithy/auth/passkey_authentications_controller.rb b/app/controllers/bitsmithy/auth/passkey_authentications_controller.rb new file mode 100644 index 0000000..09bcdd0 --- /dev/null +++ b/app/controllers/bitsmithy/auth/passkey_authentications_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Bitsmithy + module Auth + class PasskeyAuthenticationsController < ::ApplicationController + def show + ceremony = Bitsmithy::Auth.start_passkey_authentication( + return_to: params[:return_to].presence || "/" + ) + render json: { publicKey: ceremony.options, state: ceremony.state } + end + + def create + stored = Bitsmithy::Auth.config.find_passkey_credential.call(params.dig(:credential, :id)) + return render_failure unless stored + + result = finish_authentication(stored) + return render_failure unless result.success? + + complete_authentication(result) + end + + private + + def complete_authentication(result) + evidence = result.evidence + Bitsmithy::Auth.config.update_passkey_credential.call(evidence.credential_id, evidence.signature_count) + reset_session + Bitsmithy::Auth.config.on_authenticated.call(evidence, session) + redirect_to result.metadata.fetch(:return_to) + end + + def credential_param + params.expect( + credential: [ + :id, :rawId, :type, + { response: %i[clientDataJSON authenticatorData signature userHandle] } + ] + ).to_h + end + + def finish_authentication(stored) + Bitsmithy::Auth.finish_passkey_authentication( + credential: credential_param, + state: params[:state], + stored_credential: stored + ) + end + + def render_failure + render json: { error: :invalid_passkey_authentication }, status: :unprocessable_content + end + end + end +end diff --git a/app/controllers/bitsmithy/auth/passkey_registrations_controller.rb b/app/controllers/bitsmithy/auth/passkey_registrations_controller.rb new file mode 100644 index 0000000..16cd741 --- /dev/null +++ b/app/controllers/bitsmithy/auth/passkey_registrations_controller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Bitsmithy + module Auth + class PasskeyRegistrationsController < ::ApplicationController + def show + context = registration_context + return head :forbidden unless context + + ceremony = Bitsmithy::Auth.start_passkey_registration(**context) + render json: { publicKey: ceremony.options, state: ceremony.state } + end + + def create + context = registration_context + return head :forbidden unless context + + result = finish_registration(context) + return render json: { error: result.error }, status: :unprocessable_content unless result.success? + + Bitsmithy::Auth.config.store_passkey_credential.call(result.metadata.fetch(:credential), params[:name]) + head :created + end + + private + + def credential_param + params.expect( + credential: [ + :id, :rawId, :type, + { response: [:clientDataJSON, :attestationObject, { transports: [] }] } + ] + ).to_h + end + + def finish_registration(context) + Bitsmithy::Auth.finish_passkey_registration( + credential: credential_param, + state: params[:state], + user_handle: context.fetch(:user_handle) + ) + end + + def registration_context + Bitsmithy::Auth.config.passkey_registration_context.call(request, session) + end + end + end +end diff --git a/app/controllers/bitsmithy/auth/sessions_controller.rb b/app/controllers/bitsmithy/auth/sessions_controller.rb deleted file mode 100644 index 16dab32..0000000 --- a/app/controllers/bitsmithy/auth/sessions_controller.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -module Bitsmithy - module Auth - class SessionsController < ::ApplicationController - include Bitsmithy::Auth::Controller - include Concerns::Localization - - def new - render "bitsmithy/auth/sessions/new" - end - - def create - phone = params[:phone] - result = Bitsmithy::Auth.send_code(phone) - - if result.success? - session[:bitsmithy_auth_pending_phone] = phone - redirect_to "/auth/code" - else - @error = error_msg(result.error) - render :new - end - end - - def edit - render "bitsmithy/auth/sessions/edit" - end - - def update - phone = session[:bitsmithy_auth_pending_phone] - result = Bitsmithy::Auth.verify_code(phone, params[:code]) - - if result.success? - handle_verify_success(result) - else - @error = error_msg(result.error) - render :edit - end - end - - def destroy - sign_out - redirect_to Bitsmithy::Auth.config.after_sign_out_path - end - - private - - def handle_verify_success(result) - sign_in(token: result.token) - session.delete(:bitsmithy_auth_pending_phone) - Bitsmithy::Auth.config.on_verified&.call(current_identity) - redirect_to Bitsmithy::Auth.config.after_sign_in_path - end - end - end -end diff --git a/app/mailers/bitsmithy/auth/magic_link_mailer.rb b/app/mailers/bitsmithy/auth/magic_link_mailer.rb new file mode 100644 index 0000000..f3fbd08 --- /dev/null +++ b/app/mailers/bitsmithy/auth/magic_link_mailer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Bitsmithy + module Auth + class MagicLinkMailer < ActionMailer::Base + def entry + @magic_link_url = params.fetch(:url) + @expires_at = params.fetch(:expires_at) + mail( + to: params.fetch(:to), + from: Bitsmithy::Auth.config.magic_link_sender, + subject: "Your secure sign-in link" + ) + end + end + end +end diff --git a/app/views/bitsmithy/auth/magic_link_mailer/entry.html.erb b/app/views/bitsmithy/auth/magic_link_mailer/entry.html.erb new file mode 100644 index 0000000..c127ebb --- /dev/null +++ b/app/views/bitsmithy/auth/magic_link_mailer/entry.html.erb @@ -0,0 +1,4 @@ +

Continue securely

+

Open your secure sign-in link

+

This link expires at <%= @expires_at.utc.iso8601 %> and works once.

+

If you did not request it, you can ignore this email.

diff --git a/app/views/bitsmithy/auth/magic_link_mailer/entry.text.erb b/app/views/bitsmithy/auth/magic_link_mailer/entry.text.erb new file mode 100644 index 0000000..208f63f --- /dev/null +++ b/app/views/bitsmithy/auth/magic_link_mailer/entry.text.erb @@ -0,0 +1,6 @@ +Use this secure link to continue: + +<%= @magic_link_url %> + +This link expires at <%= @expires_at.utc.iso8601 %> and works once. +If you did not request it, you can ignore this email. diff --git a/bitsmithy-auth.gemspec b/bitsmithy-auth.gemspec index e72b1c0..3878b6e 100644 --- a/bitsmithy-auth.gemspec +++ b/bitsmithy-auth.gemspec @@ -8,10 +8,10 @@ Gem::Specification.new do |spec| spec.authors = ["Howard Huang"] spec.email = ["hao@hwrd.me"] - spec.summary = "Phone-number OTP authentication for Ruby applications" - spec.description = "Verify users by sending one-time codes to their phone via SMS. " \ - "Wraps Twilio Verify, provides a Rails controller helper, and ships " \ - "a mountable engine for the default sign-in flow." + spec.summary = "Stateless passwordless authentication for Ruby applications" + spec.description = "Validate Apple, Google, Email Magic Link, and Passkey credentials. " \ + "Returns Authentication Evidence while host applications own identity, " \ + "persistence, sessions, and authorization." spec.homepage = "https://github.com/bitsmithy/auth-ruby" spec.license = "MIT" spec.required_ruby_version = ">= 4.0.5" @@ -37,8 +37,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency "jwt", "~> 3.2" - spec.add_dependency "phonelib", "~> 0.10" - spec.add_dependency "twilio-ruby", "~> 7.0" + spec.add_dependency "webauthn", "~> 3.4" # For more information and examples about making a new gem, check out our # guide at: https://guides.rubygems.org/make-your-own-gem/ diff --git a/config/locales/en.yml b/config/locales/en.yml index f170bc8..5b831ff 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2,6 +2,12 @@ en: bitsmithy_auth: errors: - invalid_code: "That code doesn't match. Please try again." - invalid_phone_number: "That phone number doesn't look valid." + apple_unavailable: "Apple is unavailable. Try another way to continue." + expired_magic_link: "This link expired. Request another secure link." + invalid_apple_authentication: "Apple could not verify this request. Try again." + invalid_email: "Enter a valid email address." + google_unavailable: "Google is unavailable. Try another way to continue." + invalid_google_authentication: "Google could not verify this request. Try again." + invalid_magic_link: "This link is not valid. Request another secure link." rate_limited: "Too many attempts. Please try again later." + used_magic_link: "This link was already used. Request another secure link." diff --git a/config/routes.rb b/config/routes.rb index cc1adef..3f7d673 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,9 +1,18 @@ # frozen_string_literal: true Bitsmithy::Auth::Engine.routes.draw do - get "/sign_in" => "sessions#new", as: :sign_in - post "/send_code" => "sessions#create", as: :send_code - get "/code" => "sessions#edit", as: :code - post "/verify" => "sessions#update", as: :verify - delete "/sign_out" => "sessions#destroy", as: :sign_out + get "/passkeys/authentication" => "passkey_authentications#show", as: :passkey_authentication + post "/passkeys/authentication" => "passkey_authentications#create" + get "/passkeys/registration" => "passkey_registrations#show", as: :passkey_registration + post "/passkeys/registration" => "passkey_registrations#create" + get "/apple" => "federated_authentications#apple", as: :apple_authentication + post "/apple/callback" => "federated_authentications#apple_callback", as: :apple_callback + get "/google" => "federated_authentications#google", as: :google_authentication + get "/google/callback" => "federated_authentications#google_callback", as: :google_callback + get "/email_magic_links/new" => "email_magic_links#new", as: :new_email_magic_link + post "/email_magic_links" => "email_magic_links#create", as: :email_magic_links + get "/email_magic_links/sent" => "email_magic_links#sent", as: :email_magic_link_sent + get "/email" => "email_magic_links#exchange", as: :email_magic_link_exchange + post "/email_magic_links/verify" => "email_magic_links#verify", as: :verify_email_magic_link + get "/sign_in" => "federated_authentications#new", as: :sign_in end diff --git a/docs/adr/0001-stateless-jwt-without-revocation.md b/docs/adr/0001-stateless-jwt-without-revocation.md index 4ee7e2a..aafd0d4 100644 --- a/docs/adr/0001-stateless-jwt-without-revocation.md +++ b/docs/adr/0001-stateless-jwt-without-revocation.md @@ -1,3 +1,7 @@ +--- +status: superseded by ADR-0009 +--- + # Stateless JWT tokens with no revocation list For v0.1.0, Tokens are signed JWTs (HS256) carrying an `exp` claim and no server-side state. `sign_out` clears the session cookie but does not invalidate the Token — a leaked Token remains valid until `exp`. The default `session_duration` is 24 hours, chosen so the blast radius of a leaked Token stays small without forcing users through SMS verification every page load. diff --git a/docs/adr/0002-test-mode-rails-env-guard.md b/docs/adr/0002-test-mode-rails-env-guard.md index 2a5045c..d7e8341 100644 --- a/docs/adr/0002-test-mode-rails-env-guard.md +++ b/docs/adr/0002-test-mode-rails-env-guard.md @@ -1,3 +1,7 @@ +--- +status: superseded by ADR-0009 +--- + # Test mode is guarded by Rails environment `Bitsmithy::Auth.test_mode!` raises `ConfigurationError` unless `Rails.env.test?` or `Rails.env.development?` is true at call time. No environment-variable escape hatch is provided. In non-Rails contexts (no `Rails` constant defined) the method also refuses — those consumers can stub the OTP adapter directly instead. diff --git a/docs/adr/0003-verification-primitive-not-user-system.md b/docs/adr/0003-verification-primitive-not-user-system.md index b778aab..d7e0872 100644 --- a/docs/adr/0003-verification-primitive-not-user-system.md +++ b/docs/adr/0003-verification-primitive-not-user-system.md @@ -1,3 +1,7 @@ +--- +status: superseded by ADR-0009 +--- + # Gem is a verification primitive; lifecycle is host-app concern This gem provides exactly one thing: proof that someone controls a Phone at a point in time, expressed as a signed Token decoded into an Identity. Everything user-shaped — the User record itself, sign-up vs sign-in distinctions, phone-number change flows, hard revocation ("kick this user out now"), session management beyond Token expiry, account merge, account deletion — is left to the host app. diff --git a/docs/adr/0004-redact-phones-in-exceptions.md b/docs/adr/0004-redact-phones-in-exceptions.md index db9f278..dfe2cbe 100644 --- a/docs/adr/0004-redact-phones-in-exceptions.md +++ b/docs/adr/0004-redact-phones-in-exceptions.md @@ -1,3 +1,7 @@ +--- +status: superseded by ADR-0009 +--- + # Phone redaction is context-dependent Phone numbers in exception messages are redacted **when they represent a successfully-parsed Phone** (e.g. rate-limit failures, Twilio operation failures, any future exception that surfaces a Phone the gem already accepted as valid). The gem ships `Bitsmithy::Auth.redact_phone(phone)` as a public helper for Host apps to apply the same masking in their own log statements. diff --git a/docs/adr/0005-twilio-verify-as-otp-backend.md b/docs/adr/0005-twilio-verify-as-otp-backend.md index 40a1348..ffe1350 100644 --- a/docs/adr/0005-twilio-verify-as-otp-backend.md +++ b/docs/adr/0005-twilio-verify-as-otp-backend.md @@ -1,3 +1,7 @@ +--- +status: superseded by ADR-0009 +--- + # Twilio Verify is the OTP backend The gem delegates OTP generation, delivery, expiry, attempt limiting, and SMS-pumping fraud detection to Twilio Verify (Twilio's managed verification SaaS). The gem itself never generates, stores, expires, or counts attempts against an OTP — those concerns live on Twilio's side, keyed by a per-host-app Verify Service SID. diff --git a/docs/adr/0007-mountable-engine-drives-verification-flow.md b/docs/adr/0007-mountable-engine-drives-verification-flow.md index 966d79d..814a109 100644 --- a/docs/adr/0007-mountable-engine-drives-verification-flow.md +++ b/docs/adr/0007-mountable-engine-drives-verification-flow.md @@ -1,3 +1,7 @@ +--- +status: superseded by ADR-0009 +--- + # Mountable engine drives the verification flow, not the user lifecycle The gem ships `Bitsmithy::Auth::Engine`, a mountable Rails engine that a host app installs in one line (`mount Bitsmithy::Auth::Engine => "/auth"`) plus configuration. The engine owns the routes and the controller for the two-step Verification flow (send code → verify code → sign out), manages the pending-Phone session state internally, and on success establishes the session (stores the Token) and redirects to a configurable `after_sign_in_path`. The host injects "what a verified Identity means" downstream by reading `current_identity`/`current_phone` wherever it needs the user — there is no mandatory mid-flow callback (an optional `on_verified` hook exists for hosts that want to react at verify time). diff --git a/docs/adr/0008-engine-ships-no-views.md b/docs/adr/0008-engine-ships-no-views.md index 4665799..80eea8d 100644 --- a/docs/adr/0008-engine-ships-no-views.md +++ b/docs/adr/0008-engine-ships-no-views.md @@ -1,9 +1,6 @@ -# The engine ships no views; the host owns 100% of rendering +# The Rails Engine ships no branded views -`Bitsmithy::Auth::Engine` ships zero view templates. Its controller renders named templates (the Phone-entry form and the code-entry form) that Rails resolves from the host app's view paths — the host MUST create them before a freshly mounted engine renders anything. This is a deliberate deviation from the Devise/Clearance norm of shipping default views with an eject generator. +Authentication screens must match each Host Application's navigation, copy, visual system, accessibility behavior, and legal guidance. -The trade-off: out-of-the-box rendering versus uncompromised customizability. Shipping default views would let `mount` render immediately, but auth screens are among the most app-specific UI a host has (branding, layout, copy, form structure), so shipped defaults would be replaced in nearly every real host app — carrying an opinionated UI, a styling system, and an eject generator that almost no one keeps. We chose to ship none: the host writes two templates against a documented contract (the locals and route helpers the controller exposes), and owns every pixel from the first render. - -Error wording is the one piece of presentation the gem does NOT push entirely onto the host: the controller looks up `I18n.t("bitsmithy_auth.errors.")` for each failure Result symbol and exposes it to the re-rendered form, and the gem ships an `en` locale with sensible defaults for every error symbol. Hosts override wording by defining the same keys in their own locale files — no symbol→message mapping code, and full localizability, without the host owning the strings or the gem owning the layout. - -Consequences: the template names, the locals/route helpers passed to them, and the `bitsmithy_auth.errors.*` locale keys are a public contract host apps build against. "One `mount` line just works" is therefore qualified — routing and flow work immediately, but rendering requires the two host templates to exist first. +The RubyAuth Rails Engine renders named Host Application templates and ships an install generator with accessible starter templates rather than treating those templates as the library's production interface. +RubyAuth owns route and form contracts, safe failures, and authentication orchestration; the Host Application owns every rendered decision and can replace generated files immediately. diff --git a/docs/adr/0009-stateless-authentication-evidence.md b/docs/adr/0009-stateless-authentication-evidence.md new file mode 100644 index 0000000..f9d5293 --- /dev/null +++ b/docs/adr/0009-stateless-authentication-evidence.md @@ -0,0 +1,9 @@ +# Keep RubyAuth stateless behind Authentication Evidence + +A Host Application can authenticate one user through Apple, Google, a Verified Email, or several Passkeys, but RubyAuth cannot know how those methods map to that application's user model. + +RubyAuth will validate authentication protocols and return failure or successful Authentication Evidence without owning a database, persistent cache, user, application session, or application Token. +Its optional Rails Engine may send email, reset the browser session, call explicit host state ports, and cache only disposable public provider signing keys. + +The Host Application owns identity resolution, replay protection, rate limiting, Passkey storage, application sessions, authorization, and lifecycle. +This supersedes the phone-only verification, Twilio, stateless application Token, and phone-oriented Engine decisions in ADRs 0001, 0003, 0005, and 0007. diff --git a/lib/bitsmithy/auth.rb b/lib/bitsmithy/auth.rb index 214d6a3..a0dbfc4 100644 --- a/lib/bitsmithy/auth.rb +++ b/lib/bitsmithy/auth.rb @@ -4,19 +4,23 @@ require_relative "auth/errors" require_relative "auth/config" require_relative "auth/result" -require_relative "auth/token" -require_relative "auth/phone" -require_relative "auth/rate_limiter" -require_relative "auth/stores/memory_store" -require_relative "auth/otp/test_adapter" +require_relative "auth/authentication_evidence" +require_relative "auth/authorization_request" +require_relative "auth/apple" +require_relative "auth/email" +require_relative "auth/envelope" +require_relative "auth/magic_link" +require_relative "auth/passkey" +require_relative "auth/passkey_methods" +require_relative "auth/google" +require_relative "auth/testing" -if defined?(ActionController) - require_relative "auth/controller" - require_relative "auth/engine" if defined?(Rails::Engine) -end +require_relative "auth/engine" if defined?(Rails::Engine) module Bitsmithy module Auth + extend PasskeyMethods + class << self def config @config ||= Config.new @@ -27,58 +31,36 @@ def configure config end - def send_code(phone) - normalized = normalize_phone(phone) - rate_limiter.check!("send_code:#{normalized}") - config.otp_adapter.send_code(normalized) - rescue RateLimited - Result.failure(error: :rate_limited, phone: normalized) - rescue InvalidPhoneNumber - Result.failure(error: :invalid_phone_number, phone: phone) + def start_apple_authentication(redirect_uri:, return_to:) + Apple.start(redirect_uri: redirect_uri, return_to: return_to, config: config) end - def verify_code(phone, code) - normalized = normalize_phone(phone) - config.otp_adapter.verify_code(normalized, code) - rescue InvalidPhoneNumber - Result.failure(error: :invalid_phone_number, phone: phone) + def finish_apple_authentication(code:, state:, redirect_uri:) + Apple.finish(code: code, state: state, redirect_uri: redirect_uri, config: config) end - def decode_token(token) - Token.decode(token, config: config) + def start_google_authentication(redirect_uri:, return_to:) + Google.start(redirect_uri: redirect_uri, return_to: return_to, config: config) end - def normalize_phone(input, country: nil) - Phone.normalized(input, country: country) + def finish_google_authentication(code:, state:, redirect_uri:) + Google.finish(code: code, state: state, redirect_uri: redirect_uri, config: config) end - def redact_phone(phone) - Phone.redact(phone) + def normalize_email(input) + Email.normalize(input) end - def test_mode! - unless defined?(Rails) && (Rails.env.test? || Rails.env.development?) - raise ConfigurationError, - "#{self}.#{__method__} is only available in Rails test or development environments." - end + def request_email_magic_link(email:, redirect_uri:) + MagicLink.request(email: email, redirect_uri: redirect_uri, config: config) + end - config.signing_key ||= "test-signing-key-not-for-production" - config.otp_adapter = OTP::TestAdapter.new(config) + def verify_email_magic_link(credential) + MagicLink.verify(credential, config: config) end def reset_config! @config = nil - @rate_limiter = nil - end - - private - - def rate_limiter - @rate_limiter ||= RateLimiter.new( - store: config.rate_limit_store, - max_attempts: config.rate_limit[:per_phone], - window: config.rate_limit[:window] - ) end end end diff --git a/lib/bitsmithy/auth/apple.rb b/lib/bitsmithy/auth/apple.rb new file mode 100644 index 0000000..f47eec3 --- /dev/null +++ b/lib/bitsmithy/auth/apple.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Bitsmithy + module Auth + module Apple + AUTHORIZATION_ENDPOINT = "https://appleid.apple.com/auth/authorize" + CEREMONY_TTL = 600 + ISSUER = "https://appleid.apple.com" + PURPOSE = "apple_authentication" + + module_function + + def start(redirect_uri:, return_to:, config:) + Authorization.new(config).start(redirect_uri: redirect_uri, return_to: return_to) + end + + def finish(code:, state:, redirect_uri:, config:) + Verification.new(config).finish(code: code, state: state, redirect_uri: redirect_uri) + end + end + end +end + +require_relative "apple/authorization" +require_relative "apple/client_secret" +require_relative "apple/http_client" +require_relative "apple/verification" diff --git a/lib/bitsmithy/auth/apple/authorization.rb b/lib/bitsmithy/auth/apple/authorization.rb new file mode 100644 index 0000000..5380f43 --- /dev/null +++ b/lib/bitsmithy/auth/apple/authorization.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "base64" +require "digest" +require "securerandom" +require "uri" + +module Bitsmithy + module Auth + module Apple + class Authorization + def initialize(config) + @config = config + end + + def start(redirect_uri:, return_to:) + verifier = SecureRandom.urlsafe_base64(32, padding: false) + nonce = SecureRandom.urlsafe_base64(24, padding: false) + state = Envelope.seal(state_payload(redirect_uri, return_to, verifier, nonce), key: config.envelope_key) + AuthorizationRequest.new(url: authorization_url(redirect_uri, verifier, nonce, state)) + end + + private + + attr_reader :config + + def authorization_url(redirect_uri, verifier, nonce, state) + query = URI.encode_www_form(authorization_parameters(redirect_uri, verifier, nonce, state)) + "#{AUTHORIZATION_ENDPOINT}?#{query}" + end + + def authorization_parameters(redirect_uri, verifier, nonce, state) + { + client_id: config.apple_client_id, + redirect_uri: validated_redirect_uri(redirect_uri), + response_type: "code", + response_mode: "form_post", + scope: "email" + }.merge(ceremony_parameters(verifier, nonce, state)) + end + + def ceremony_parameters(verifier, nonce, state) + { + state: state, + nonce: nonce, + code_challenge: code_challenge(verifier), + code_challenge_method: "S256" + } + end + + def code_challenge(verifier) + digest = Digest::SHA256.digest(verifier) + Base64.urlsafe_encode64(digest, padding: false) + end + + def state_payload(redirect_uri, return_to, verifier, nonce) + now = config.clock.call + { + "purpose" => PURPOSE, + "redirect_uri" => validated_redirect_uri(redirect_uri), + "return_to" => validated_return_to(return_to), + "verifier" => verifier, + "nonce" => nonce, + "issued_at" => now.to_i, + "expires_at" => (now + CEREMONY_TTL).to_i + } + end + + def validated_redirect_uri(input) + uri = URI.parse(input.to_s) + return uri.to_s if uri.is_a?(URI::HTTPS) && uri.host + + raise ConfigurationError, "Apple redirect_uri must be an absolute HTTPS URL" + rescue URI::InvalidURIError + raise ConfigurationError, "Apple redirect_uri must be an absolute HTTPS URL" + end + + def validated_return_to(input) + value = input.to_s + return value if value.start_with?("/") && !value.start_with?("//") + + raise ConfigurationError, "return_to must be an application-relative path" + end + end + end + end +end diff --git a/lib/bitsmithy/auth/apple/client_secret.rb b/lib/bitsmithy/auth/apple/client_secret.rb new file mode 100644 index 0000000..fe53e97 --- /dev/null +++ b/lib/bitsmithy/auth/apple/client_secret.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "jwt" +require "openssl" + +module Bitsmithy + module Auth + module Apple + module ClientSecret + LIFETIME = 30 * 24 * 60 * 60 + + module_function + + def issue(config) + key = OpenSSL::PKey.read(config.apple_private_key) + JWT.encode(claims(config), key, "ES256", kid: config.apple_key_id) + rescue OpenSSL::PKey::PKeyError, TypeError + raise ConfigurationError, "apple_private_key must be a valid EC private key" + end + + def claims(config) + issued_at = config.clock.call.to_i + { + iss: config.apple_team_id, + iat: issued_at, + exp: issued_at + LIFETIME, + aud: ISSUER, + sub: config.apple_client_id + } + end + private_class_method :claims + end + end + end +end diff --git a/lib/bitsmithy/auth/apple/http_client.rb b/lib/bitsmithy/auth/apple/http_client.rb new file mode 100644 index 0000000..7b910d1 --- /dev/null +++ b/lib/bitsmithy/auth/apple/http_client.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "json" +require "net/http" + +module Bitsmithy + module Auth + module Apple + class HttpClient + JWKS_URI = URI("https://appleid.apple.com/auth/keys") + TOKEN_URI = URI("https://appleid.apple.com/auth/token") + CACHE_SECONDS = 300 + + def initialize(open_timeout: 5, read_timeout: 10, clock: -> { Time.now.utc }) + @open_timeout = open_timeout + @read_timeout = read_timeout + @clock = clock + end + + def exchange(**parameters) + request = Net::HTTP::Post.new(TOKEN_URI) + request.set_form_data(parameters.merge(grant_type: "authorization_code")) + json_response(TOKEN_URI, request) + end + + def jwks + return @jwks if @jwks && @jwks_expires_at > @clock.call + + @jwks = json_response(JWKS_URI, Net::HTTP::Get.new(JWKS_URI)) + @jwks_expires_at = @clock.call + CACHE_SECONDS + @jwks + end + + private + + def json_response(uri, request) + response = http_for(uri).request(request) + raise ProviderUnavailable unless response.is_a?(Net::HTTPSuccess) + + JSON.parse(response.body) + rescue JSON::ParserError, SocketError, SystemCallError, Timeout::Error + raise ProviderUnavailable + end + + def http_for(uri) + Net::HTTP.new(uri.host, uri.port).tap do |http| + http.use_ssl = true + http.open_timeout = @open_timeout + http.read_timeout = @read_timeout + end + end + end + end + end +end diff --git a/lib/bitsmithy/auth/apple/verification.rb b/lib/bitsmithy/auth/apple/verification.rb new file mode 100644 index 0000000..1e739df --- /dev/null +++ b/lib/bitsmithy/auth/apple/verification.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "jwt" + +module Bitsmithy + module Auth + module Apple + class Verification + def initialize(config) + @config = config + end + + def finish(code:, state:, redirect_uri:) + ceremony = decoded_ceremony(state, redirect_uri) + token_response = exchange_code(code, ceremony) + claims = decoded_claims(token_response.fetch("id_token")) + validate_claims!(claims, ceremony.fetch("nonce"), config.clock.call) + successful_result(claims, ceremony) + rescue ProviderUnavailable + Result.failure(error: :apple_unavailable) + rescue InvalidEmail, InvalidEnvelope, JWT::DecodeError, KeyError, TypeError + Result.failure(error: :invalid_apple_authentication) + end + + private + + attr_reader :config + + def decoded_ceremony(state, redirect_uri) + ceremony = Envelope.open(state, key: config.envelope_key) + raise InvalidEnvelope unless ceremony["purpose"] == PURPOSE + raise InvalidEnvelope unless ceremony["redirect_uri"] == redirect_uri + raise ExpiredEnvelope if ceremony.fetch("expires_at") <= config.clock.call.to_i + + ceremony + end + + def decoded_claims(id_token) + JWT.decode(id_token, nil, true, **decode_options).first + end + + def decode_options + { + algorithms: ["RS256"], + jwks: config.apple_provider_client.jwks, + iss: ISSUER, + verify_iss: true, + aud: config.apple_client_id, + verify_aud: true, + verify_expiration: false + } + end + + def evidence_from(claims) + AuthenticationEvidence.federated( + provider: :apple, + subject: claims.fetch("sub"), + email: normalized_email(claims), + authenticated_at: Time.at(claims.fetch("iat")).utc + ) + end + + def exchange_code(code, ceremony) + config.apple_provider_client.exchange( + code: code, + redirect_uri: ceremony.fetch("redirect_uri"), + code_verifier: ceremony.fetch("verifier"), + client_id: config.apple_client_id, + client_secret: config.apple_client_secret + ) + end + + def normalized_email(claims) + return unless claims["email"] + raise InvalidEnvelope unless [true, "true"].include?(claims["email_verified"]) + + Email.normalize(claims.fetch("email")) + end + + def successful_result(claims, ceremony) + Result.success( + evidence: evidence_from(claims), + metadata: { return_to: ceremony.fetch("return_to") } + ) + end + + def validate_claims!(claims, nonce, now) + raise InvalidEnvelope unless claims["nonce"] == nonce + raise InvalidEnvelope unless claims.fetch("exp") > now.to_i + raise InvalidEnvelope unless claims.fetch("iat") <= now.to_i + end + end + end + end +end diff --git a/lib/bitsmithy/auth/authentication_evidence.rb b/lib/bitsmithy/auth/authentication_evidence.rb new file mode 100644 index 0000000..2ca09fa --- /dev/null +++ b/lib/bitsmithy/auth/authentication_evidence.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Bitsmithy + module Auth + EMPTY_AUTHENTICATION_VALUES = { + provider: nil, + subject: nil, + credential_id: nil, + user_handle: nil, + signature_count: nil + }.freeze + + AuthenticationEvidence = Data.define( + :sign_in_method, + :authenticated_at, + :email, + :provider, + :subject, + :credential_id, + :user_handle, + :signature_count, + :replay_id + ) do + def self.email(email:, authenticated_at:, replay_id:) + new( + **EMPTY_AUTHENTICATION_VALUES, + sign_in_method: :email, + authenticated_at: authenticated_at, + email: email, + replay_id: replay_id + ) + end + + def self.federated(provider:, subject:, email:, authenticated_at:) + new( + **EMPTY_AUTHENTICATION_VALUES, + sign_in_method: provider, + authenticated_at: authenticated_at, + email: email, + provider: provider, + subject: subject, + replay_id: nil + ) + end + + def self.passkey(credential_id:, user_handle:, signature_count:, authenticated_at:) + new( + **EMPTY_AUTHENTICATION_VALUES, + sign_in_method: :passkey, + authenticated_at: authenticated_at, + email: nil, + credential_id: credential_id, + user_handle: user_handle, + signature_count: signature_count, + replay_id: nil + ) + end + end + end +end diff --git a/lib/bitsmithy/auth/identity.rb b/lib/bitsmithy/auth/authorization_request.rb similarity index 50% rename from lib/bitsmithy/auth/identity.rb rename to lib/bitsmithy/auth/authorization_request.rb index dccb71f..2fc5fc4 100644 --- a/lib/bitsmithy/auth/identity.rb +++ b/lib/bitsmithy/auth/authorization_request.rb @@ -2,8 +2,6 @@ module Bitsmithy module Auth - Identity = Data.define( - :phone, :issued_at, :expires_at - ) + AuthorizationRequest = Data.define(:url) end end diff --git a/lib/bitsmithy/auth/config.rb b/lib/bitsmithy/auth/config.rb index 28edd7a..e74a118 100644 --- a/lib/bitsmithy/auth/config.rb +++ b/lib/bitsmithy/auth/config.rb @@ -1,50 +1,85 @@ # frozen_string_literal: true require_relative "errors" -require_relative "stores/memory_store" -require_relative "stores/rails_cache_store" module Bitsmithy module Auth class Config - JWT_ISSUER = "bitsmithy-auth" - JWT_ALGORITHM = "HS256" - DEFAULT_SESSION_DURATION = 86_400 # 24h per ADR-0001 - DEFAULT_RATE_LIMIT = { per_phone: 5, window: 3_600 }.freeze - CACHE_MISSING_WARNING = "[bitsmithy-auth] Rails.cache is nil — configure config.cache_store " \ - "for cross-worker rate limiting. Falling back to MemoryStore." - - attr_accessor :signing_key, :otp_adapter, :session_duration, :rate_limit, - :twilio_account_sid, :twilio_auth_token, :twilio_verify_service_sid, - :sign_in_path, - :after_sign_in_path, :after_sign_out_path, - :on_verified - attr_writer :rate_limit_store + attr_accessor :envelope_key, :magic_link_delivery, :magic_link_ttl, :clock, + :magic_link_redirect_uri, :claim_magic_link, :on_authenticated, + :after_authentication_path, :allow_email_request, + :magic_link_sender, :google_client_id, :google_client_secret, + :google_redirect_uri, :apple_client_id, :apple_redirect_uri, + :apple_team_id, :apple_key_id, :apple_private_key, + :passkey_origin, :passkey_relying_party_id, + :passkey_relying_party_name, :passkey_registration_context, + :store_passkey_credential, :find_passkey_credential, + :update_passkey_credential + attr_writer :apple_client_secret, :apple_provider_client, :google_provider_client, + :passkey_relying_party def initialize - @session_duration = DEFAULT_SESSION_DURATION - @rate_limit = DEFAULT_RATE_LIMIT.dup - @after_sign_in_path = "/" - @after_sign_out_path = "/" + @magic_link_ttl = 600 + @clock = -> { Time.now.utc } + @after_authentication_path = "/" + @allow_email_request = ->(_email, _request) { true } + @magic_link_delivery = ->(message) { deliver_magic_link(message) } end - def rate_limit_store - @rate_limit_store ||= if defined?(Rails) && Rails.respond_to?(:cache) - if Rails.cache - Stores::RailsCacheStore.new(Rails.cache) - else - warn CACHE_MISSING_WARNING - Stores::MemoryStore.new - end - else - Stores::MemoryStore.new - end + def apple_client_secret + @apple_client_secret || Apple::ClientSecret.issue(self) + end + + def apple_provider_client + @apple_provider_client ||= Apple::HttpClient.new + end + + def google_provider_client + @google_provider_client ||= Google::HttpClient.new + end + + def passkey_relying_party + @passkey_relying_party ||= WebAuthn::RelyingParty.new( + allowed_origins: [passkey_origin], + id: passkey_relying_party_id, + name: passkey_relying_party_name, + verify_attestation_statement: false, + acceptable_attestation_types: ["None"] + ) end def validate! - required = %i[twilio_account_sid twilio_auth_token twilio_verify_service_sid signing_key] - missing = required.select { |k| public_send(k).nil? } - raise ConfigurationError, "missing required config: #{missing}" if missing.any? + missing = required_settings.select { |setting| public_send(setting).nil? } + return true if missing.empty? + + raise ConfigurationError, "missing required config: #{missing.join(", ")}" + end + + private + + def deliver_magic_link(message) + MagicLinkMailer.with( + to: message.to, + url: message.url, + expires_at: message.expires_at + ).entry.deliver_later + end + + def required_settings + required = %i[envelope_key on_authenticated] + required.push(:magic_link_redirect_uri, :magic_link_sender, :claim_magic_link) if magic_link_redirect_uri + required.push(:google_client_secret, :google_redirect_uri) if google_client_id + required.push(:apple_team_id, :apple_key_id, :apple_private_key, :apple_redirect_uri) if apple_client_id + required.push(*passkey_settings) if passkey_origin + required + end + + def passkey_settings + %i[ + passkey_relying_party_id passkey_relying_party_name + passkey_registration_context store_passkey_credential + find_passkey_credential update_passkey_credential + ] end end end diff --git a/lib/bitsmithy/auth/controller.rb b/lib/bitsmithy/auth/controller.rb deleted file mode 100644 index da7e2b0..0000000 --- a/lib/bitsmithy/auth/controller.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -require "active_support/concern" - -module Bitsmithy - module Auth - module Controller - extend ActiveSupport::Concern - - SESSION_KEY = :bitsmithy_auth_token - - def current_identity - return @current_identity if defined?(@current_identity) - - @current_identity = begin - token = session[SESSION_KEY] - token && Bitsmithy::Auth.decode_token(token) - rescue Bitsmithy::Auth::InvalidToken - nil - end - end - - def current_phone - current_identity&.phone - end - - def authenticated? - !current_identity.nil? - end - - def require_authentication! - return if authenticated? - - redirect_to Bitsmithy::Auth.config.sign_in_path || Engine.routes.url_helpers.sign_in_path - end - - def sign_in(token:) - session[SESSION_KEY] = token - reset_current_identity! - end - - def sign_out - session.delete(SESSION_KEY) - reset_current_identity! - end - - private - - def reset_current_identity! - remove_instance_variable(:@current_identity) if defined?(@current_identity) - end - end - end -end diff --git a/lib/bitsmithy/auth/email.rb b/lib/bitsmithy/auth/email.rb new file mode 100644 index 0000000..e0de962 --- /dev/null +++ b/lib/bitsmithy/auth/email.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Bitsmithy + module Auth + module Email + PATTERN = /\A[^\s@]+@[^\s@]+\z/ + + module_function + + def normalize(input) + normalized = input.to_s.strip.downcase + raise InvalidEmail unless normalized.match?(PATTERN) + + normalized.freeze + end + end + end +end diff --git a/lib/bitsmithy/auth/envelope.rb b/lib/bitsmithy/auth/envelope.rb new file mode 100644 index 0000000..f26f521 --- /dev/null +++ b/lib/bitsmithy/auth/envelope.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "base64" +require "json" +require "openssl" +require "securerandom" + +module Bitsmithy + module Auth + module Envelope + ALGORITHM = "aes-256-gcm" + IV_BYTES = 12 + KEY_BYTES = 32 + TAG_BYTES = 16 + VERSION = "v1" + + module_function + + def seal(payload, key:) + cipher, iv = encryption_context(key) + ciphertext = cipher.update(JSON.generate(payload)) + cipher.final + encoded = Base64.urlsafe_encode64(iv + cipher.auth_tag(TAG_BYTES) + ciphertext, padding: false) + "#{VERSION}.#{encoded}" + end + + def open(token, key:) + iv, tag, ciphertext = encoded_parts(token) + cipher = decryption_context(key, iv, tag) + JSON.parse(cipher.update(ciphertext) + cipher.final) + rescue ArgumentError, JSON::ParserError, OpenSSL::Cipher::CipherError + raise InvalidEnvelope + end + + def encryption_context(key) + cipher = configured_cipher(:encrypt, key) + initialization_vector = SecureRandom.random_bytes(IV_BYTES) + cipher.iv = initialization_vector + cipher.auth_data = VERSION + [cipher, initialization_vector] + end + private_class_method :encryption_context + + def decryption_context(key, initialization_vector, tag) + configured_cipher(:decrypt, key).tap do |cipher| + cipher.iv = initialization_vector + cipher.auth_tag = tag + cipher.auth_data = VERSION + end + end + private_class_method :decryption_context + + def configured_cipher(direction, key) + validate_key!(key) + OpenSSL::Cipher.new(ALGORITHM).public_send(direction).tap do |cipher| + cipher.key = key.byteslice(0, KEY_BYTES) + end + end + private_class_method :configured_cipher + + def encoded_parts(token) + version, encoded = token.to_s.split(".", 2) + raise InvalidEnvelope unless version == VERSION && encoded + + split_payload(Base64.urlsafe_decode64(encoded)) + end + private_class_method :encoded_parts + + def split_payload(decoded) + iv = decoded.byteslice(0, IV_BYTES) + tag = decoded.byteslice(IV_BYTES, TAG_BYTES) + ciphertext = decoded.byteslice((IV_BYTES + TAG_BYTES)..) + raise InvalidEnvelope unless iv&.bytesize == IV_BYTES && tag&.bytesize == TAG_BYTES && ciphertext + + [iv, tag, ciphertext] + end + private_class_method :split_payload + + def validate_key!(key) + return if key.is_a?(String) && key.bytesize >= KEY_BYTES + + raise ConfigurationError, "envelope_key must contain at least #{KEY_BYTES} bytes" + end + private_class_method :validate_key! + end + end +end diff --git a/lib/bitsmithy/auth/errors.rb b/lib/bitsmithy/auth/errors.rb index 906b1c9..a36de08 100644 --- a/lib/bitsmithy/auth/errors.rb +++ b/lib/bitsmithy/auth/errors.rb @@ -4,8 +4,9 @@ module Bitsmithy module Auth class Error < StandardError; end class ConfigurationError < Error; end - class InvalidPhoneNumber < Error; end - class InvalidToken < Error; end - class RateLimited < Error; end + class ExpiredEnvelope < Error; end + class InvalidEmail < Error; end + class InvalidEnvelope < Error; end + class ProviderUnavailable < Error; end end end diff --git a/lib/bitsmithy/auth/google.rb b/lib/bitsmithy/auth/google.rb new file mode 100644 index 0000000..a58f6df --- /dev/null +++ b/lib/bitsmithy/auth/google.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Bitsmithy + module Auth + module Google + AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth" + CEREMONY_TTL = 600 + ISSUERS = ["https://accounts.google.com", "accounts.google.com"].freeze + PURPOSE = "google_authentication" + + module_function + + def start(redirect_uri:, return_to:, config:) + Authorization.new(config).start(redirect_uri: redirect_uri, return_to: return_to) + end + + def finish(code:, state:, redirect_uri:, config:) + Verification.new(config).finish(code: code, state: state, redirect_uri: redirect_uri) + end + end + end +end + +require_relative "google/authorization" +require_relative "google/http_client" +require_relative "google/verification" diff --git a/lib/bitsmithy/auth/google/authorization.rb b/lib/bitsmithy/auth/google/authorization.rb new file mode 100644 index 0000000..ecc4caf --- /dev/null +++ b/lib/bitsmithy/auth/google/authorization.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "base64" +require "digest" +require "securerandom" +require "uri" + +module Bitsmithy + module Auth + module Google + class Authorization + def initialize(config) + @config = config + end + + def start(redirect_uri:, return_to:) + verifier = SecureRandom.urlsafe_base64(32, padding: false) + nonce = SecureRandom.urlsafe_base64(24, padding: false) + state = Envelope.seal(state_payload(redirect_uri, return_to, verifier, nonce), key: config.envelope_key) + AuthorizationRequest.new(url: authorization_url(redirect_uri, verifier, nonce, state)) + end + + private + + attr_reader :config + + def authorization_url(redirect_uri, verifier, nonce, state) + query = URI.encode_www_form(authorization_parameters(redirect_uri, verifier, nonce, state)) + "#{AUTHORIZATION_ENDPOINT}?#{query}" + end + + def authorization_parameters(redirect_uri, verifier, nonce, state) + { + client_id: config.google_client_id, + redirect_uri: validated_redirect_uri(redirect_uri), + response_type: "code", + scope: "openid email", + state: state, + nonce: nonce, + code_challenge: code_challenge(verifier), + code_challenge_method: "S256" + } + end + + def code_challenge(verifier) + digest = Digest::SHA256.digest(verifier) + Base64.urlsafe_encode64(digest, padding: false) + end + + def state_payload(redirect_uri, return_to, verifier, nonce) + now = config.clock.call + { + "purpose" => PURPOSE, + "redirect_uri" => validated_redirect_uri(redirect_uri), + "return_to" => validated_return_to(return_to), + "verifier" => verifier, + "nonce" => nonce, + "issued_at" => now.to_i, + "expires_at" => (now + CEREMONY_TTL).to_i + } + end + + def validated_redirect_uri(input) + uri = URI.parse(input.to_s) + return uri.to_s if uri.is_a?(URI::HTTPS) && uri.host + + raise ConfigurationError, "Google redirect_uri must be an absolute HTTPS URL" + rescue URI::InvalidURIError + raise ConfigurationError, "Google redirect_uri must be an absolute HTTPS URL" + end + + def validated_return_to(input) + value = input.to_s + return value if value.start_with?("/") && !value.start_with?("//") + + raise ConfigurationError, "return_to must be an application-relative path" + end + end + end + end +end diff --git a/lib/bitsmithy/auth/google/http_client.rb b/lib/bitsmithy/auth/google/http_client.rb new file mode 100644 index 0000000..d95a8f3 --- /dev/null +++ b/lib/bitsmithy/auth/google/http_client.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "json" +require "net/http" + +module Bitsmithy + module Auth + module Google + class HttpClient + JWKS_URI = URI("https://www.googleapis.com/oauth2/v3/certs") + TOKEN_URI = URI("https://oauth2.googleapis.com/token") + CACHE_SECONDS = 300 + + def initialize(open_timeout: 5, read_timeout: 10, clock: -> { Time.now.utc }) + @open_timeout = open_timeout + @read_timeout = read_timeout + @clock = clock + end + + def exchange(**parameters) + request = Net::HTTP::Post.new(TOKEN_URI) + request.set_form_data(parameters.merge(grant_type: "authorization_code")) + json_response(TOKEN_URI, request) + end + + def jwks + return @jwks if @jwks && @jwks_expires_at > @clock.call + + @jwks = json_response(JWKS_URI, Net::HTTP::Get.new(JWKS_URI)) + @jwks_expires_at = @clock.call + CACHE_SECONDS + @jwks + end + + private + + def json_response(uri, request) + response = http_for(uri).request(request) + raise ProviderUnavailable unless response.is_a?(Net::HTTPSuccess) + + JSON.parse(response.body) + rescue JSON::ParserError, SocketError, SystemCallError, Timeout::Error + raise ProviderUnavailable + end + + def http_for(uri) + Net::HTTP.new(uri.host, uri.port).tap do |http| + http.use_ssl = true + http.open_timeout = @open_timeout + http.read_timeout = @read_timeout + end + end + end + end + end +end diff --git a/lib/bitsmithy/auth/google/verification.rb b/lib/bitsmithy/auth/google/verification.rb new file mode 100644 index 0000000..61b5fd2 --- /dev/null +++ b/lib/bitsmithy/auth/google/verification.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "jwt" + +module Bitsmithy + module Auth + module Google + class Verification + def initialize(config) + @config = config + end + + def finish(code:, state:, redirect_uri:) + ceremony = decoded_ceremony(state, redirect_uri) + token_response = exchange_code(code, ceremony) + claims = decoded_claims(token_response.fetch("id_token")) + validate_claims!(claims, ceremony.fetch("nonce"), config.clock.call) + successful_result(claims, ceremony) + rescue ProviderUnavailable + Result.failure(error: :google_unavailable) + rescue InvalidEmail, InvalidEnvelope, JWT::DecodeError, KeyError, TypeError + Result.failure(error: :invalid_google_authentication) + end + + private + + attr_reader :config + + def decoded_ceremony(state, redirect_uri) + ceremony = Envelope.open(state, key: config.envelope_key) + raise InvalidEnvelope unless ceremony["purpose"] == PURPOSE + raise InvalidEnvelope unless ceremony["redirect_uri"] == redirect_uri + raise ExpiredEnvelope if ceremony.fetch("expires_at") <= config.clock.call.to_i + + ceremony + end + + def decoded_claims(id_token) + JWT.decode(id_token, nil, true, **decode_options).first + end + + def decode_options + { + algorithms: ["RS256"], + jwks: config.google_provider_client.jwks, + iss: ISSUERS, + verify_iss: true, + aud: config.google_client_id, + verify_aud: true, + verify_expiration: false + } + end + + def evidence_from(claims) + AuthenticationEvidence.federated( + provider: :google, + subject: claims.fetch("sub"), + email: Email.normalize(claims.fetch("email")), + authenticated_at: Time.at(claims.fetch("iat")).utc + ) + end + + def exchange_code(code, ceremony) + config.google_provider_client.exchange( + code: code, + redirect_uri: ceremony.fetch("redirect_uri"), + code_verifier: ceremony.fetch("verifier"), + client_id: config.google_client_id, + client_secret: config.google_client_secret + ) + end + + def successful_result(claims, ceremony) + Result.success( + evidence: evidence_from(claims), + metadata: { return_to: ceremony.fetch("return_to") } + ) + end + + def validate_claims!(claims, nonce, now) + raise InvalidEnvelope unless claims["nonce"] == nonce + raise InvalidEnvelope unless claims["email_verified"] == true + raise InvalidEnvelope unless claims.fetch("exp") > now.to_i + raise InvalidEnvelope unless claims.fetch("iat") <= now.to_i + end + end + end + end +end diff --git a/lib/bitsmithy/auth/magic_link.rb b/lib/bitsmithy/auth/magic_link.rb new file mode 100644 index 0000000..e113493 --- /dev/null +++ b/lib/bitsmithy/auth/magic_link.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "securerandom" +require "uri" + +module Bitsmithy + module Auth + module MagicLink + Message = Data.define(:to, :url, :expires_at) + PURPOSE = "email_magic_link" + + module_function + + def request(email:, redirect_uri:, config:) + normalized_email = Email.normalize(email) + message = issue_message(normalized_email, redirect_uri, config) + config.magic_link_delivery.call(message) + Result.success(metadata: { email: normalized_email }) + rescue InvalidEmail + Result.failure(error: :invalid_email, metadata: { email: email.to_s }) + end + + def verify(credential, config:) + payload = Envelope.open(credential, key: config.envelope_key) + validate_payload!(payload, config.clock.call) + Result.success(evidence: evidence_from(payload)) + rescue ExpiredEnvelope + Result.failure(error: :expired_magic_link) + rescue InvalidEmail, InvalidEnvelope, KeyError, TypeError + Result.failure(error: :invalid_magic_link) + end + + def issue_message(email, redirect_uri, config) + issued_at = config.clock.call + expires_at = issued_at + config.magic_link_ttl + credential = Envelope.seal(payload(email, issued_at, expires_at), key: config.envelope_key) + Message.new( + to: email, + url: "#{validated_redirect_uri(redirect_uri)}#credential=#{credential}", + expires_at: expires_at + ) + end + private_class_method :issue_message + + def payload(email, issued_at, expires_at) + { + "purpose" => PURPOSE, + "email" => email, + "replay_id" => SecureRandom.uuid, + "issued_at" => issued_at.to_i, + "expires_at" => expires_at.to_i + } + end + private_class_method :payload + + def validate_payload!(payload, now) + raise InvalidEnvelope unless payload["purpose"] == PURPOSE + raise ExpiredEnvelope if payload.fetch("expires_at") <= now.to_i + end + private_class_method :validate_payload! + + def evidence_from(payload) + AuthenticationEvidence.email( + email: Email.normalize(payload.fetch("email")), + authenticated_at: Time.at(payload.fetch("issued_at")).utc, + replay_id: payload.fetch("replay_id") + ) + end + private_class_method :evidence_from + + def validated_redirect_uri(input) + uri = URI.parse(input.to_s) + return uri.to_s if uri.is_a?(URI::HTTPS) && uri.host + + raise ConfigurationError, "magic-link redirect_uri must be an absolute HTTPS URL" + rescue URI::InvalidURIError + raise ConfigurationError, "magic-link redirect_uri must be an absolute HTTPS URL" + end + private_class_method :validated_redirect_uri + end + end +end diff --git a/lib/bitsmithy/auth/otp/test_adapter.rb b/lib/bitsmithy/auth/otp/test_adapter.rb deleted file mode 100644 index 1676dda..0000000 --- a/lib/bitsmithy/auth/otp/test_adapter.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require "bitsmithy/auth/result" -require "bitsmithy/auth/token" - -module Bitsmithy - module Auth - module OTP - class TestAdapter - def initialize(config) - @config = config - end - - MAGIC_TEST_CODE = "000000" - - def send_code(phone) - Result.success(phone: phone) - end - - def verify_code(phone, code) - if code == MAGIC_TEST_CODE - Result.success(token: Token.encode(phone: phone, config: @config), phone: phone) - else - Result.failure(error: :invalid_code, phone: phone) - end - end - end - end - end -end diff --git a/lib/bitsmithy/auth/otp/twilio_adapter.rb b/lib/bitsmithy/auth/otp/twilio_adapter.rb deleted file mode 100644 index 521e295..0000000 --- a/lib/bitsmithy/auth/otp/twilio_adapter.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -require "twilio-ruby" -require "bitsmithy/auth/result" -require "bitsmithy/auth/token" - -module Bitsmithy - module Auth - module OTP - class TwilioAdapter - ERROR_CODE_MAP = { - 60_200 => :invalid_phone_number, - 60_202 => :max_check_attempts, - 60_203 => :max_send_attempts - }.freeze - - def initialize(config) - @config = config - end - - def send_code(phone) - verify_service.verifications.create(to: phone, channel: "sms") - Result.success(phone: phone, channel: :sms) - rescue Twilio::REST::RestError => e - Result.failure(error: map_error(e), phone: phone) - end - - def verify_code(phone, code) - check = verify_service.verification_checks.create(to: phone, code: code) - - if check.status == "approved" - Result.success(token: Token.encode(phone: phone, config: @config), phone: phone) - else - Result.failure(error: :invalid_code, phone: phone) - end - rescue Twilio::REST::RestError => e - Result.failure(error: map_error(e), phone: phone) - end - - private - - def verify_service - @verify_service ||= twilio_client.verify.v2.services(@config.twilio_verify_service_sid) - end - - def twilio_client - @twilio_client ||= Twilio::REST::Client.new( - @config.twilio_account_sid, - @config.twilio_auth_token - ) - end - - def map_error(error) - ERROR_CODE_MAP[error.code] || map_status_code(error.status_code) || :twilio_error - end - - def map_status_code(code) - case code - when 401 then :twilio_authentication_error - when 429 then :twilio_rate_limited - when 500..529 then :twilio_service_unavailable - end - end - end - end - end -end diff --git a/lib/bitsmithy/auth/passkey.rb b/lib/bitsmithy/auth/passkey.rb new file mode 100644 index 0000000..f3d7bc3 --- /dev/null +++ b/lib/bitsmithy/auth/passkey.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "json" +require "webauthn" + +module Bitsmithy + module Auth + module Passkey + AUTHENTICATION_PURPOSE = "passkey_authentication" + CEREMONY_TTL = 300 + REGISTRATION_PURPOSE = "passkey_registration" + Ceremony = Data.define(:options, :state) + Credential = Data.define(:credential_id, :public_key, :signature_count) + StoredCredential = Data.define(:credential_id, :public_key, :signature_count, :user_handle) + + module_function + + def finish_authentication(credential:, state:, stored_credential:, config:) + Authentication.new(config).finish( + credential: credential, + state: state, + stored_credential: stored_credential + ) + end + + def finish_registration(credential:, state:, user_handle:, config:) + Registration.new(config).finish( + credential: credential, + state: state, + user_handle: user_handle + ) + end + + def start_authentication(return_to:, config:) + Authentication.new(config).start(return_to: return_to) + end + + def start_registration(user_handle:, user_name:, exclude_credential_ids:, config:) + Registration.new(config).start( + user_handle: user_handle, + user_name: user_name, + exclude_credential_ids: exclude_credential_ids + ) + end + end + end +end + +require_relative "passkey/authentication" +require_relative "passkey/registration" diff --git a/lib/bitsmithy/auth/passkey/authentication.rb b/lib/bitsmithy/auth/passkey/authentication.rb new file mode 100644 index 0000000..f1714f7 --- /dev/null +++ b/lib/bitsmithy/auth/passkey/authentication.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Bitsmithy + module Auth + module Passkey + class Authentication + def initialize(config) + @config = config + end + + def start(return_to:) + options = config.passkey_relying_party.options_for_authentication( + allow: [], + user_verification: "required" + ) + state = Envelope.seal(ceremony_state(options.challenge, return_to), key: config.envelope_key) + Ceremony.new(options: JSON.parse(JSON.generate(options.as_json)), state: state) + end + + def finish(credential:, state:, stored_credential:) + ceremony = Envelope.open(state, key: config.envelope_key) + validate_ceremony!(ceremony, config.clock.call) + raise InvalidEnvelope unless credential["id"] == stored_credential.credential_id + + verified = verify(credential, ceremony, stored_credential) + successful_result(verified, stored_credential, ceremony) + rescue InvalidEnvelope, KeyError, TypeError, WebAuthn::Error + Result.failure(error: :invalid_passkey_authentication) + end + + private + + attr_reader :config + + def ceremony_state(challenge, return_to) + now = config.clock.call + { + "purpose" => AUTHENTICATION_PURPOSE, + "challenge" => challenge, + "return_to" => validated_return_to(return_to), + "issued_at" => now.to_i, + "expires_at" => (now + CEREMONY_TTL).to_i + } + end + + def evidence(verified, stored) + AuthenticationEvidence.passkey( + credential_id: verified.id, + user_handle: stored.user_handle, + signature_count: verified.sign_count, + authenticated_at: config.clock.call + ) + end + + def successful_result(verified, stored, ceremony) + Result.success( + evidence: evidence(verified, stored), + metadata: { return_to: ceremony.fetch("return_to") } + ) + end + + def validate_ceremony!(ceremony, now) + raise InvalidEnvelope unless ceremony["purpose"] == AUTHENTICATION_PURPOSE + raise InvalidEnvelope if ceremony.fetch("expires_at") <= now.to_i + end + + def validated_return_to(input) + value = input.to_s + return value if value.start_with?("/") && !value.start_with?("//") + + raise ConfigurationError, "return_to must be an application-relative path" + end + + def verify(credential, ceremony, stored) + config.passkey_relying_party.verify_authentication( + credential, + ceremony.fetch("challenge"), + public_key: stored.public_key, + sign_count: stored.signature_count, + user_presence: true, + user_verification: true + ) + end + end + end + end +end diff --git a/lib/bitsmithy/auth/passkey/registration.rb b/lib/bitsmithy/auth/passkey/registration.rb new file mode 100644 index 0000000..6d027f2 --- /dev/null +++ b/lib/bitsmithy/auth/passkey/registration.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Bitsmithy + module Auth + module Passkey + class Registration + def initialize(config) + @config = config + end + + def start(user_handle:, user_name:, exclude_credential_ids:) + options = registration_options(user_handle, user_name, exclude_credential_ids) + state = Envelope.seal(ceremony_state(options.challenge, user_handle), key: config.envelope_key) + Ceremony.new(options: JSON.parse(JSON.generate(options.as_json)), state: state) + end + + def finish(credential:, state:, user_handle:) + ceremony = Envelope.open(state, key: config.envelope_key) + validate_ceremony!(ceremony, user_handle, config.clock.call) + verified = verify(credential, ceremony) + Result.success(metadata: { credential: credential_values(verified) }) + rescue InvalidEnvelope, KeyError, TypeError, WebAuthn::Error + Result.failure(error: :invalid_passkey_registration) + end + + private + + attr_reader :config + + def ceremony_state(challenge, user_handle) + now = config.clock.call + { + "purpose" => REGISTRATION_PURPOSE, + "challenge" => challenge, + "user_handle" => user_handle, + "issued_at" => now.to_i, + "expires_at" => (now + CEREMONY_TTL).to_i + } + end + + def credential_values(verified) + Credential.new( + credential_id: verified.id, + public_key: verified.public_key, + signature_count: verified.sign_count + ) + end + + def registration_options(user_handle, user_name, excluded) + config.passkey_relying_party.options_for_registration( + user: { id: user_handle, name: user_name }, + exclude: excluded, + authenticator_selection: { resident_key: "required", user_verification: "required" }, + attestation: "none" + ) + end + + def validate_ceremony!(ceremony, user_handle, now) + raise InvalidEnvelope unless ceremony["purpose"] == REGISTRATION_PURPOSE + raise InvalidEnvelope unless ceremony["user_handle"] == user_handle + raise InvalidEnvelope if ceremony.fetch("expires_at") <= now.to_i + end + + def verify(credential, ceremony) + config.passkey_relying_party.verify_registration( + credential, + ceremony.fetch("challenge"), + user_presence: true, + user_verification: true + ) + end + end + end + end +end diff --git a/lib/bitsmithy/auth/passkey_methods.rb b/lib/bitsmithy/auth/passkey_methods.rb new file mode 100644 index 0000000..00f83b1 --- /dev/null +++ b/lib/bitsmithy/auth/passkey_methods.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Bitsmithy + module Auth + module PasskeyMethods + def finish_passkey_authentication(credential:, state:, stored_credential:) + Passkey.finish_authentication( + credential: credential, + state: state, + stored_credential: stored_credential, + config: config + ) + end + + def start_passkey_authentication(return_to: "/") + Passkey.start_authentication(return_to: return_to, config: config) + end + + def finish_passkey_registration(credential:, state:, user_handle:) + Passkey.finish_registration( + credential: credential, + state: state, + user_handle: user_handle, + config: config + ) + end + + def start_passkey_registration(user_handle:, user_name:, exclude_credential_ids: []) + Passkey.start_registration( + user_handle: user_handle, + user_name: user_name, + exclude_credential_ids: exclude_credential_ids, + config: config + ) + end + end + end +end diff --git a/lib/bitsmithy/auth/phone.rb b/lib/bitsmithy/auth/phone.rb deleted file mode 100644 index 64df04f..0000000 --- a/lib/bitsmithy/auth/phone.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -require "phonelib" -require_relative "errors" - -module Bitsmithy - module Auth - module Phone - def self.normalized(input, country: nil) - parse(input, country: country).e164 - end - - def self.redact(input) - parsed = parse(input) - # Mask every digit EXCEPT the last 4 — lookahead ensures we don't - # mask the trailing four characters. Country code is preserved - # separately on the line below. - national_redacted = parsed.national(false).gsub(/.(?=.{4})/, "*") - - "+#{parsed.country_code}#{national_redacted}" - end - - def self.parse(input, country: nil) - parsed = country ? Phonelib.parse(input, country) : Phonelib.parse(input) - raise InvalidPhoneNumber, "could not parse: #{input}" unless parsed.valid? - - parsed - end - end - end -end diff --git a/lib/bitsmithy/auth/rate_limiter.rb b/lib/bitsmithy/auth/rate_limiter.rb deleted file mode 100644 index a5ecece..0000000 --- a/lib/bitsmithy/auth/rate_limiter.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require_relative "errors" - -module Bitsmithy - module Auth - class RateLimiter - def initialize(store:, max_attempts:, window:) - @store = store - @max_attempts = max_attempts - @window = window - end - - def check!(key) - raise RateLimited if @store.increment(key, @window) > @max_attempts - end - end - end -end diff --git a/lib/bitsmithy/auth/result.rb b/lib/bitsmithy/auth/result.rb index 8c13367..453c198 100644 --- a/lib/bitsmithy/auth/result.rb +++ b/lib/bitsmithy/auth/result.rb @@ -2,17 +2,17 @@ module Bitsmithy module Auth - Result = Data.define(:success, :error, :token, :channel, :phone) do + Result = Data.define(:success, :error, :evidence, :metadata) do def success? success end - def self.success(token: nil, channel: :sms, phone: nil) - new(success: true, error: nil, token: token, channel: channel, phone: phone) + def self.success(evidence: nil, metadata: {}) + new(success: true, error: nil, evidence: evidence, metadata: metadata.freeze) end - def self.failure(error:, channel: :sms, phone: nil) - new(success: false, error: error, phone: phone, channel: channel, token: nil) + def self.failure(error:, metadata: {}) + new(success: false, error: error, evidence: nil, metadata: metadata.freeze) end end end diff --git a/lib/bitsmithy/auth/stores/memory_store.rb b/lib/bitsmithy/auth/stores/memory_store.rb deleted file mode 100644 index 7c8a0db..0000000 --- a/lib/bitsmithy/auth/stores/memory_store.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module Bitsmithy - module Auth - module Stores - class MemoryStore - def initialize - @data = {} - @mutex = Mutex.new - end - - def increment(key, window_seconds) - @mutex.synchronize do - now = Time.now.to_i - @data.delete_if { |_k, v| v[:expires_at] <= now } - - @data[key] ||= { count: 0, expires_at: now + window_seconds } - @data[key][:count] += 1 - end - end - end - end - end -end diff --git a/lib/bitsmithy/auth/stores/rails_cache_store.rb b/lib/bitsmithy/auth/stores/rails_cache_store.rb deleted file mode 100644 index 9136c4c..0000000 --- a/lib/bitsmithy/auth/stores/rails_cache_store.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module Bitsmithy - module Auth - module Stores - # Wraps an ActiveSupport::Cache::Store (typically Rails.cache) so the - # rate limiter works across worker processes when a distributed cache - # (Redis, Memcached) is configured. - # - # Falls back gracefully: if Rails.cache is nil the caller should use - # MemoryStore instead — see Config#rate_limit_store. - class RailsCacheStore - def initialize(cache) - @cache = cache - end - - # Increment the counter for +key+ and return the new value. - # Expires the entry after +window_seconds+. - def increment(key, window_seconds) - @cache.increment(key, 1, expires_in: window_seconds) - end - end - end - end -end diff --git a/lib/bitsmithy/auth/testing.rb b/lib/bitsmithy/auth/testing.rb new file mode 100644 index 0000000..96720f3 --- /dev/null +++ b/lib/bitsmithy/auth/testing.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Bitsmithy + module Auth + module Testing + ALLOWED_ENVIRONMENTS = %i[development test].freeze + DEFAULT_TIME = Time.utc(2000, 1, 1) + + module_function + + def authentication_evidence(sign_in_method, environment:, authenticated_at: DEFAULT_TIME, **values) + ensure_allowed!(environment) + factory = "#{sign_in_method}_evidence" + raise ArgumentError, "unsupported test Sign-in Method: #{sign_in_method}" unless respond_to?(factory, true) + + send(factory, authenticated_at, values) + end + + def apple_evidence(authenticated_at, values) + federated_evidence(:apple, authenticated_at, values) + end + private_class_method :apple_evidence + + def email_evidence(authenticated_at, values) + AuthenticationEvidence.email( + email: Email.normalize(values.fetch(:email)), + authenticated_at: authenticated_at, + replay_id: values.fetch(:replay_id, "test-replay-id") + ) + end + private_class_method :email_evidence + + def federated_evidence(provider, authenticated_at, values) + AuthenticationEvidence.federated( + provider: provider, + subject: values.fetch(:subject, "test-provider-subject"), + email: values[:email] && Email.normalize(values[:email]), + authenticated_at: authenticated_at + ) + end + private_class_method :federated_evidence + + def google_evidence(authenticated_at, values) + federated_evidence(:google, authenticated_at, values) + end + private_class_method :google_evidence + + def passkey_evidence(authenticated_at, values) + AuthenticationEvidence.passkey( + credential_id: values.fetch(:credential_id, "test-credential"), + user_handle: values.fetch(:user_handle, "test-user-handle"), + signature_count: values.fetch(:signature_count, 0), + authenticated_at: authenticated_at + ) + end + private_class_method :passkey_evidence + + def ensure_allowed!(environment) + return if ALLOWED_ENVIRONMENTS.include?(environment.to_sym) + + raise ConfigurationError, "RubyAuth test support is unavailable in production" + end + private_class_method :ensure_allowed! + end + end +end diff --git a/lib/bitsmithy/auth/token.rb b/lib/bitsmithy/auth/token.rb deleted file mode 100644 index d610a95..0000000 --- a/lib/bitsmithy/auth/token.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -require "jwt" -require_relative "config" -require_relative "identity" -require_relative "errors" - -module Bitsmithy - module Auth - module Token - def self.encode(phone:, config:) - iat = Time.now.to_i - payload = { sub: phone, iat: iat, exp: iat + config.session_duration, iss: Config::JWT_ISSUER } - JWT.encode(payload, config.signing_key, Config::JWT_ALGORITHM) - end - - def self.decode(token, config:) - payload, _header = JWT.decode(token, config.signing_key, true, - algorithm: Config::JWT_ALGORITHM, - iss: Config::JWT_ISSUER, verify_iss: true) - Identity.new( - phone: payload["sub"], - issued_at: Time.at(payload["iat"]), - expires_at: Time.at(payload["exp"]) - ) - rescue JWT::DecodeError => e - raise InvalidToken, e.message - end - end - end -end diff --git a/lib/bitsmithy/auth/version.rb b/lib/bitsmithy/auth/version.rb index 47fe3c9..87a0991 100644 --- a/lib/bitsmithy/auth/version.rb +++ b/lib/bitsmithy/auth/version.rb @@ -2,6 +2,6 @@ module Bitsmithy module Auth - VERSION = "0.1.0" + VERSION = "0.2.0" end end diff --git a/lib/generators/bitsmithy/auth/install_generator.rb b/lib/generators/bitsmithy/auth/install_generator.rb index 9f1295b..9a5a338 100644 --- a/lib/generators/bitsmithy/auth/install_generator.rb +++ b/lib/generators/bitsmithy/auth/install_generator.rb @@ -8,8 +8,12 @@ class InstallGenerator < Rails::Generators::Base def install template "initializer.rb.erb", "config/initializers/bitsmithy_auth.rb" - template "new.html.erb", "app/views/bitsmithy/auth/sessions/new.html.erb" - template "edit.html.erb", "app/views/bitsmithy/auth/sessions/edit.html.erb" + template "federated_authentications/new.html.erb", + "app/views/bitsmithy/auth/federated_authentications/new.html.erb" + %w[new sent error exchange].each do |name| + template "email_magic_links/#{name}.html.erb", + "app/views/bitsmithy/auth/email_magic_links/#{name}.html.erb" + end insert_mount_line end diff --git a/lib/generators/bitsmithy/auth/templates/edit.html.erb b/lib/generators/bitsmithy/auth/templates/edit.html.erb deleted file mode 100644 index 7045c22..0000000 --- a/lib/generators/bitsmithy/auth/templates/edit.html.erb +++ /dev/null @@ -1,12 +0,0 @@ -<%%= form_tag verify_path do %> - <%% if @error %> -
<%%= @error %>
- <%% end %> - -

A code was sent to <%%= @phone %>

- - <%%= label_tag :code, "Verification code" %> - <%%= text_field_tag :code, nil, inputmode: "numeric", autocomplete: "one-time-code", pattern: "[0-9]{6}", required: true %> - - <%%= submit_tag "Verify" %> -<%% end %> diff --git a/lib/generators/bitsmithy/auth/templates/email_magic_links/error.html.erb b/lib/generators/bitsmithy/auth/templates/email_magic_links/error.html.erb new file mode 100644 index 0000000..139195d --- /dev/null +++ b/lib/generators/bitsmithy/auth/templates/email_magic_links/error.html.erb @@ -0,0 +1,5 @@ +
+

Request another secure link

+

<%%= @error %>

+ <%%= link_to "Continue with email", new_email_magic_link_path %> +
diff --git a/lib/generators/bitsmithy/auth/templates/email_magic_links/exchange.html.erb b/lib/generators/bitsmithy/auth/templates/email_magic_links/exchange.html.erb new file mode 100644 index 0000000..347271d --- /dev/null +++ b/lib/generators/bitsmithy/auth/templates/email_magic_links/exchange.html.erb @@ -0,0 +1,17 @@ +
+

Opening securely…

+

Checking this single-use link.

+ <%%= form_with url: verify_email_magic_link_path, id: "magic-link-exchange" do %> + <%%= hidden_field_tag :credential %> + <%% end %> +
+ diff --git a/lib/generators/bitsmithy/auth/templates/email_magic_links/new.html.erb b/lib/generators/bitsmithy/auth/templates/email_magic_links/new.html.erb new file mode 100644 index 0000000..0dba049 --- /dev/null +++ b/lib/generators/bitsmithy/auth/templates/email_magic_links/new.html.erb @@ -0,0 +1,9 @@ +
+

Continue with email

+ <%% if @error.present? %>

<%%= @error %>

<%% end %> + <%%= form_with url: email_magic_links_path do %> + <%%= label_tag :email, "Email address" %> + <%%= email_field_tag :email, params[:email], required: true, autocomplete: "username webauthn" %> + <%%= submit_tag "Email me a link" %> + <%% end %> +
diff --git a/lib/generators/bitsmithy/auth/templates/email_magic_links/sent.html.erb b/lib/generators/bitsmithy/auth/templates/email_magic_links/sent.html.erb new file mode 100644 index 0000000..48384f3 --- /dev/null +++ b/lib/generators/bitsmithy/auth/templates/email_magic_links/sent.html.erb @@ -0,0 +1,4 @@ +
+

Check your email

+

Open the secure link that was just sent.

+
diff --git a/lib/generators/bitsmithy/auth/templates/federated_authentications/new.html.erb b/lib/generators/bitsmithy/auth/templates/federated_authentications/new.html.erb new file mode 100644 index 0000000..83c2c0c --- /dev/null +++ b/lib/generators/bitsmithy/auth/templates/federated_authentications/new.html.erb @@ -0,0 +1,6 @@ +
+

Continue securely

+ <%%= link_to "Continue with Apple", apple_authentication_path %> + <%%= link_to "Continue with Google", google_authentication_path %> + <%%= link_to "Continue with email", new_email_magic_link_path %> +
diff --git a/lib/generators/bitsmithy/auth/templates/initializer.rb.erb b/lib/generators/bitsmithy/auth/templates/initializer.rb.erb index ecab32e..d7d5d9f 100644 --- a/lib/generators/bitsmithy/auth/templates/initializer.rb.erb +++ b/lib/generators/bitsmithy/auth/templates/initializer.rb.erb @@ -1,20 +1,28 @@ -Bitsmithy::Auth.configure do |c| - c.signing_key = ENV.fetch("BITSMITHY_AUTH_SIGNING_KEY") +Bitsmithy::Auth.configure do |config| + config.envelope_key = ENV.fetch("BITSMITHY_AUTH_ENVELOPE_KEY") + config.magic_link_sender = ENV.fetch("AUTH_EMAIL_FROM") + config.magic_link_redirect_uri = ENV.fetch("AUTH_EMAIL_REDIRECT_URI") - if Rails.env.production? - require "bitsmithy/auth/otp/twilio_adapter" + config.google_client_id = ENV.fetch("GOOGLE_CLIENT_ID") + config.google_client_secret = ENV.fetch("GOOGLE_CLIENT_SECRET") + config.google_redirect_uri = ENV.fetch("GOOGLE_REDIRECT_URI") - c.twilio_account_sid = ENV.fetch("TWILIO_ACCOUNT_SID") - c.twilio_auth_token = ENV.fetch("TWILIO_AUTH_TOKEN") - c.twilio_verify_service_sid = ENV.fetch("TWILIO_VERIFY_SERVICE_SID") - c.otp_adapter = Bitsmithy::Auth::OTP::TwilioAdapter.new(c) - else - Bitsmithy::Auth.test_mode! - end + config.apple_client_id = ENV.fetch("APPLE_CLIENT_ID") + config.apple_team_id = ENV.fetch("APPLE_TEAM_ID") + config.apple_key_id = ENV.fetch("APPLE_KEY_ID") + config.apple_private_key = ENV.fetch("APPLE_PRIVATE_KEY") + config.apple_redirect_uri = ENV.fetch("APPLE_REDIRECT_URI") - # Optional overrides (uncomment and set as needed): - # c.after_sign_in_path = "/" - # c.after_sign_out_path = "/" - # c.sign_in_path = Bitsmithy::Auth::Engine.routes.url_helpers.sign_in_path - # c.on_verified = ->(identity) { ... } + config.passkey_origin = ENV.fetch("PASSKEY_ORIGIN") + config.passkey_relying_party_id = ENV.fetch("PASSKEY_RELYING_PARTY_ID") + config.passkey_relying_party_name = ENV.fetch("PASSKEY_RELYING_PARTY_NAME") + + # Supply application-owned callbacks before calling config.validate!: + # config.allow_email_request + # config.claim_magic_link + # config.on_authenticated + # config.passkey_registration_context + # config.store_passkey_credential + # config.find_passkey_credential + # config.update_passkey_credential end diff --git a/lib/generators/bitsmithy/auth/templates/new.html.erb b/lib/generators/bitsmithy/auth/templates/new.html.erb deleted file mode 100644 index d391e66..0000000 --- a/lib/generators/bitsmithy/auth/templates/new.html.erb +++ /dev/null @@ -1,10 +0,0 @@ -<%%= form_tag send_code_path do %> - <%% if @error %> -
<%%= @error %>
- <%% end %> - - <%%= label_tag :phone, "Phone number" %> - <%%= telephone_field_tag :phone, nil, inputmode: "tel", autocomplete: "tel", required: true %> - - <%%= submit_tag "Send code" %> -<%% end %> diff --git a/test/bitsmithy/auth/otp/test_twilio_adapter.rb b/test/bitsmithy/auth/otp/test_twilio_adapter.rb deleted file mode 100644 index a76f162..0000000 --- a/test/bitsmithy/auth/otp/test_twilio_adapter.rb +++ /dev/null @@ -1,130 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -module Bitsmithy - module Auth - module OTP - class TestTwilioAdapter < Minitest::Test - include ConfigHelper - - def setup - super - Bitsmithy::Auth.configure do |c| - c.signing_key = "x" * 64 - c.twilio_account_sid = "ACtest" - c.twilio_auth_token = "secret" - c.twilio_verify_service_sid = "VAtest" - end - end - - def test_send_code_returns_success_result_when_twilio_accepts - adapter = TwilioAdapter.new(Bitsmithy::Auth.config) - verifications = mock - verifications.expects(:create).with(to: "+12127363100", channel: "sms").returns(stub(status: "pending")) - adapter.stubs(:verify_service).returns(stub(verifications: verifications)) - - result = adapter.send_code("+12127363100") - - assert_predicate result, :success? - end - - def test_verify_code_returns_success_with_decodable_token_when_check_is_approved - adapter = TwilioAdapter.new(Bitsmithy::Auth.config) - checks = mock - checks.expects(:create).with(to: "+12127363100", code: "123456").returns(stub(status: "approved")) - adapter.stubs(:verify_service).returns(stub(verification_checks: checks)) - - result = adapter.verify_code("+12127363100", "123456") - identity = Bitsmithy::Auth.decode_token(result.token) - - assert_equal "+12127363100", identity.phone - end - - def test_send_code_maps_twilio_error_60200_to_invalid_phone_number - adapter = TwilioAdapter.new(Bitsmithy::Auth.config) - verifications = mock - verifications.expects(:create).raises(twilio_rest_error(code: 60_200)) - adapter.stubs(:verify_service).returns(stub(verifications: verifications)) - - result = adapter.send_code("+12127363100") - - assert_equal :invalid_phone_number, result.error - end - - def test_verify_code_maps_twilio_error_60202_to_max_check_attempts - adapter = TwilioAdapter.new(Bitsmithy::Auth.config) - checks = mock - checks.expects(:create).raises(twilio_rest_error(code: 60_202)) - adapter.stubs(:verify_service).returns(stub(verification_checks: checks)) - - result = adapter.verify_code("+12127363100", "123456") - - assert_equal :max_check_attempts, result.error - end - - def test_send_code_maps_twilio_error_60203_to_max_send_attempts - adapter = TwilioAdapter.new(Bitsmithy::Auth.config) - verifications = mock - verifications.expects(:create).raises(twilio_rest_error(code: 60_203)) - adapter.stubs(:verify_service).returns(stub(verifications: verifications)) - - result = adapter.send_code("+12127363100") - - assert_equal :max_send_attempts, result.error - end - - def test_send_code_maps_twilio_401_to_authentication_error - adapter = TwilioAdapter.new(Bitsmithy::Auth.config) - verifications = mock - verifications.expects(:create).raises(twilio_rest_error(code: 20_003, status: 401)) - adapter.stubs(:verify_service).returns(stub(verifications: verifications)) - - result = adapter.send_code("+12127363100") - - assert_equal :twilio_authentication_error, result.error - end - - def test_send_code_maps_twilio_429_to_rate_limited - adapter = TwilioAdapter.new(Bitsmithy::Auth.config) - verifications = mock - verifications.expects(:create).raises(twilio_rest_error(code: 20_429, status: 429)) - adapter.stubs(:verify_service).returns(stub(verifications: verifications)) - - result = adapter.send_code("+12127363100") - - assert_equal :twilio_rate_limited, result.error - end - - def test_send_code_maps_twilio_5xx_to_service_unavailable - adapter = TwilioAdapter.new(Bitsmithy::Auth.config) - verifications = mock - verifications.expects(:create).raises(twilio_rest_error(code: 20_500, status: 503)) - adapter.stubs(:verify_service).returns(stub(verifications: verifications)) - - result = adapter.send_code("+12127363100") - - assert_equal :twilio_service_unavailable, result.error - end - - def test_send_code_falls_back_to_twilio_error_for_unmapped_code - adapter = TwilioAdapter.new(Bitsmithy::Auth.config) - verifications = mock - verifications.expects(:create).raises(twilio_rest_error(code: 99_999, status: 418)) - adapter.stubs(:verify_service).returns(stub(verifications: verifications)) - - result = adapter.send_code("+12127363100") - - assert_equal :twilio_error, result.error - end - - private - - def twilio_rest_error(code:, status: 400) - response = stub(status_code: status, body: { "code" => code, "message" => "test" }) - Twilio::REST::RestError.new("test", response) - end - end - end - end -end diff --git a/test/bitsmithy/auth/test_apple_authentication.rb b/test/bitsmithy/auth/test_apple_authentication.rb new file mode 100644 index 0000000..1a5abfe --- /dev/null +++ b/test/bitsmithy/auth/test_apple_authentication.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class TestAppleAuthentication < Minitest::Test + FakeProvider = Data.define(:id_token, :jwks) do + def exchange(**) + { "id_token" => id_token } + end + end + + def setup + super + Bitsmithy::Auth.reset_config! + Bitsmithy::Auth.configure do |config| + config.envelope_key = "e" * 32 + config.apple_client_id = "com.cookmark.web" + config.apple_client_secret = "test-client-secret" + config.clock = -> { Time.utc(2026, 8, 28, 12, 0, 0) } + end + end + + def test_returning_apple_subject_can_omit_the_email + configure_provider(id_token_for(apple_parameters.fetch("nonce"), include_email: false)) + + result = finish_apple + + assert_predicate result, :success? + assert_equal "apple-subject", result.evidence.subject + assert_nil result.evidence.email + end + + def test_finishes_apple_private_relay_as_federated_authentication_evidence + configure_provider(id_token_for(apple_parameters.fetch("nonce"))) + + result = finish_apple + + assert_apple_private_relay_evidence(result) + end + + def test_starts_apple_with_email_scope_form_post_and_pkce + parameters = apple_parameters + + assert_equal "email", parameters.fetch("scope") + assert_equal "form_post", parameters.fetch("response_mode") + assert_equal "code", parameters.fetch("response_type") + assert_equal "S256", parameters.fetch("code_challenge_method") + assert_predicate parameters.fetch("nonce"), :present? + assert_predicate parameters.fetch("state"), :present? + end + + private + + def apple_parameters + @apple_parameters ||= begin + authorization = Bitsmithy::Auth.start_apple_authentication( + redirect_uri: "https://cookmark.example/auth/apple/callback", + return_to: "/recipes" + ) + URI.decode_www_form(URI.parse(authorization.url).query).to_h + end + end + + def assert_apple_private_relay_evidence(result) + assert_predicate result, :success? + assert_equal :apple, result.evidence.sign_in_method + assert_equal "apple-subject", result.evidence.subject + assert_equal "relay@privaterelay.appleid.com", result.evidence.email + assert_equal "/recipes", result.metadata.fetch(:return_to) + end + + def configure_provider(id_token) + jwk = JWT::JWK.new(@signing_key.public_key, kid: "apple-test-key").export + Bitsmithy::Auth.config.apple_provider_client = FakeProvider.new( + id_token: id_token, + jwks: { "keys" => [jwk] } + ) + end + + def finish_apple + Bitsmithy::Auth.finish_apple_authentication( + code: "authorization-code", + state: apple_parameters.fetch("state"), + redirect_uri: "https://cookmark.example/auth/apple/callback" + ) + end + + def id_token_for(nonce, include_email: true) + @signing_key = OpenSSL::PKey::RSA.generate(2048) + claims = id_token_claims(nonce) + claims.merge!(email_claims) if include_email + JWT.encode(claims, @signing_key, "RS256", kid: "apple-test-key") + end + + def id_token_claims(nonce) + { + iss: "https://appleid.apple.com", + aud: "com.cookmark.web", + sub: "apple-subject", + nonce: nonce, + iat: Time.utc(2026, 8, 28, 12, 0, 0).to_i, + exp: Time.utc(2026, 8, 28, 12, 10, 0).to_i + } + end + + def email_claims + { + email: "relay@privaterelay.appleid.com", + email_verified: "true", + is_private_email: "true" + } + end +end diff --git a/test/bitsmithy/auth/test_apple_client_secret.rb b/test/bitsmithy/auth/test_apple_client_secret.rb new file mode 100644 index 0000000..7a732df --- /dev/null +++ b/test/bitsmithy/auth/test_apple_client_secret.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class TestAppleClientSecret < Minitest::Test + def setup + super + Bitsmithy::Auth.reset_config! + @private_key = OpenSSL::PKey::EC.generate("prime256v1") + Bitsmithy::Auth.configure do |config| + config.apple_client_id = "com.cookmark.web" + config.apple_team_id = "TEAM123" + config.apple_key_id = "KEY123" + config.apple_private_key = @private_key.to_pem + config.clock = -> { Time.utc(2026, 8, 28, 12, 0, 0) } + end + end + + def test_builds_the_apple_client_secret_from_host_credentials + client_secret = Bitsmithy::Auth.config.apple_client_secret + + claims, header = JWT.decode(client_secret, @private_key, true, algorithm: "ES256") + + assert_equal "TEAM123", claims.fetch("iss") + assert_equal "com.cookmark.web", claims.fetch("sub") + assert_equal "https://appleid.apple.com", claims.fetch("aud") + assert_equal "KEY123", header.fetch("kid") + end +end diff --git a/test/bitsmithy/auth/test_controller.rb b/test/bitsmithy/auth/test_controller.rb deleted file mode 100644 index be1e694..0000000 --- a/test/bitsmithy/auth/test_controller.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -module Bitsmithy - module Auth - class TestController < Minitest::Test - include ConfigHelper - - # Minimal Rails-controller stand-in. Exposes a session Hash and - # mixes in the concern under test. - class FakeController - include Bitsmithy::Auth::Controller - - attr_reader :session - - def initialize(session = {}) - @session = session - end - end - - def test_current_phone_returns_phone_from_valid_token_in_session - configure_for_tests - token = Bitsmithy::Auth.verify_code("+12127363100", "000000").token - controller = FakeController.new(bitsmithy_auth_token: token) - - assert_equal "+12127363100", controller.current_phone - end - - def test_current_phone_returns_nil_when_session_is_empty - configure_for_tests - controller = FakeController.new({}) - - assert_nil controller.current_phone - end - - def test_authenticated_returns_false_when_session_is_empty - configure_for_tests - controller = FakeController.new({}) - - refute_predicate controller, :authenticated? - end - - def test_current_phone_returns_nil_for_invalid_token_in_session - configure_for_tests - controller = FakeController.new(bitsmithy_auth_token: "garbage.jwt.token") - - assert_nil controller.current_phone - end - - def test_sign_in_writes_token_to_session_and_invalidates_memo - configure_for_tests - controller = FakeController.new({}) - controller.current_phone # memoise the (nil) identity - token = Bitsmithy::Auth.verify_code("+12127363100", "000000").token - - controller.sign_in(token: token) - - assert_equal "+12127363100", controller.current_phone - end - - def test_sign_out_clears_session_and_invalidates_memo - configure_for_tests - token = Bitsmithy::Auth.verify_code("+12127363100", "000000").token - controller = FakeController.new(bitsmithy_auth_token: token) - controller.current_phone # memoise the identity - - controller.sign_out - - assert_nil controller.current_phone - end - - def test_concern_defines_require_authentication_method - assert_respond_to FakeController.new, :require_authentication! - end - end - end -end diff --git a/test/bitsmithy/auth/test_email_magic_link.rb b/test/bitsmithy/auth/test_email_magic_link.rb new file mode 100644 index 0000000..b0dcdf2 --- /dev/null +++ b/test/bitsmithy/auth/test_email_magic_link.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class TestEmailMagicLink < Minitest::Test + def setup + super + Bitsmithy::Auth.reset_config! + @deliveries = [] + @now = Time.utc(2026, 8, 28, 12, 0, 0) + Bitsmithy::Auth.configure do |config| + config.envelope_key = "e" * 32 + config.magic_link_delivery = ->(message) { @deliveries << message } + config.clock = -> { @now } + end + end + + def test_requests_a_magic_link_for_the_normalized_email + result = request_magic_link(" Alex+Meals@Example.COM ") + + assert_predicate result, :success? + assert_equal "alex+meals@example.com", result.metadata.fetch(:email) + assert_equal 1, @deliveries.length + assert_equal "alex+meals@example.com", @deliveries.fetch(0).to + assert_match %r{\Ahttps://cookmark\.example/auth/email#credential=}, @deliveries.fetch(0).url + end + + def test_tampered_magic_link_returns_a_safe_failure + credential = request_credential + tamper_index = credential.length / 2 + replacement = credential[tamper_index] == "x" ? "y" : "x" + tampered = credential.dup.tap { |value| value[tamper_index] = replacement } + + result = Bitsmithy::Auth.verify_email_magic_link(tampered) + + assert_equal :invalid_magic_link, result.error + assert_nil result.evidence + end + + def test_expired_magic_link_returns_a_safe_failure + credential = request_credential + @now += 601 + + result = Bitsmithy::Auth.verify_email_magic_link(credential) + + assert_equal :expired_magic_link, result.error + end + + def test_verifies_a_magic_link_as_email_authentication_evidence + result = Bitsmithy::Auth.verify_email_magic_link(request_credential) + + assert_predicate result, :success? + assert_equal :email, result.evidence.sign_in_method + assert_equal "alex@example.com", result.evidence.email + assert_equal @now, result.evidence.authenticated_at + assert_predicate result.evidence.replay_id, :present? + end + + private + + def request_credential + request_magic_link("alex@example.com") + URI.parse(@deliveries.fetch(0).url).fragment.delete_prefix("credential=") + end + + def request_magic_link(email) + Bitsmithy::Auth.request_email_magic_link( + email: email, + redirect_uri: "https://cookmark.example/auth/email" + ) + end +end diff --git a/test/bitsmithy/auth/test_google_authentication.rb b/test/bitsmithy/auth/test_google_authentication.rb new file mode 100644 index 0000000..90640ca --- /dev/null +++ b/test/bitsmithy/auth/test_google_authentication.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class TestGoogleAuthentication < Minitest::Test + FakeProvider = Data.define(:id_token, :jwks) do + def exchange(**) + raise Bitsmithy::Auth::ProviderUnavailable unless id_token + + { "id_token" => id_token } + end + end + + def setup + super + Bitsmithy::Auth.reset_config! + Bitsmithy::Auth.configure do |config| + config.envelope_key = "e" * 32 + config.google_client_id = "google-client-id" + config.clock = -> { Time.utc(2026, 8, 28, 12, 0, 0) } + end + end + + def test_google_service_failure_returns_a_safe_result + Bitsmithy::Auth.config.google_provider_client = FakeProvider.new(id_token: nil, jwks: {}) + + result = finish_google + + assert_equal :google_unavailable, result.error + assert_nil result.evidence + end + + def test_finishes_google_as_federated_authentication_evidence + configure_provider(id_token_for(google_parameters.fetch("nonce"))) + + result = finish_google + + assert_google_evidence(result) + end + + def test_starts_google_with_minimal_identity_scopes_and_pkce + parameters = google_parameters + + assert_equal "openid email", parameters.fetch("scope") + assert_equal "code", parameters.fetch("response_type") + assert_equal "S256", parameters.fetch("code_challenge_method") + assert_predicate parameters.fetch("nonce"), :present? + assert_predicate parameters.fetch("state"), :present? + end + + private + + def assert_google_evidence(result) + assert_predicate result, :success? + assert_equal :google, result.evidence.sign_in_method + assert_equal "google-subject", result.evidence.subject + assert_equal "alex@example.com", result.evidence.email + assert_equal "/recipes", result.metadata.fetch(:return_to) + end + + def configure_provider(id_token) + jwk = JWT::JWK.new(@signing_key.public_key, kid: "test-key").export + Bitsmithy::Auth.config.google_provider_client = FakeProvider.new( + id_token: id_token, + jwks: { "keys" => [jwk] } + ) + end + + def finish_google + Bitsmithy::Auth.finish_google_authentication( + code: "authorization-code", + state: google_parameters.fetch("state"), + redirect_uri: "https://cookmark.example/auth/google/callback" + ) + end + + def google_parameters + @google_parameters ||= begin + authorization = Bitsmithy::Auth.start_google_authentication( + redirect_uri: "https://cookmark.example/auth/google/callback", + return_to: "/recipes" + ) + URI.decode_www_form(URI.parse(authorization.url).query).to_h + end + end + + def id_token_for(nonce) + @signing_key = OpenSSL::PKey::RSA.generate(2048) + JWT.encode(id_token_claims(nonce), @signing_key, "RS256", kid: "test-key") + end + + def id_token_claims(nonce) + { + iss: "https://accounts.google.com", + aud: "google-client-id", + sub: "google-subject", + email: "Alex@Example.com", + email_verified: true, + nonce: nonce, + iat: Time.utc(2026, 8, 28, 12, 0, 0).to_i, + exp: Time.utc(2026, 8, 28, 12, 10, 0).to_i + } + end +end diff --git a/test/bitsmithy/auth/test_passkey_authentication.rb b/test/bitsmithy/auth/test_passkey_authentication.rb new file mode 100644 index 0000000..b08b01c --- /dev/null +++ b/test/bitsmithy/auth/test_passkey_authentication.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class TestPasskeyAuthentication < Minitest::Test + VerifiedCredential = Data.define(:id, :sign_count) + + class VerifyingRelyingParty + attr_reader :user_verification + + def verify_authentication(_credential, _challenge, **verification) + @user_verification = verification.values_at(:user_presence, :user_verification).all? + VerifiedCredential.new(id: "credential-id", sign_count: 4) + end + end + + def setup + super + Bitsmithy::Auth.reset_config! + Bitsmithy::Auth.configure do |config| + config.envelope_key = "e" * 32 + config.passkey_origin = "https://cookmark.example" + config.passkey_relying_party_id = "cookmark.example" + config.passkey_relying_party_name = "Cookmark" + end + end + + def test_finishes_authentication_as_passkey_evidence_with_counter_update + ceremony = Bitsmithy::Auth.start_passkey_authentication + relying_party = VerifyingRelyingParty.new + Bitsmithy::Auth.config.passkey_relying_party = relying_party + + result = finish_authentication(ceremony) + + assert_passkey_evidence(result) + assert_predicate relying_party, :user_verification + end + + def test_starts_discoverable_authentication_with_local_user_verification + ceremony = Bitsmithy::Auth.start_passkey_authentication + + assert_equal [], ceremony.options.fetch("allowCredentials") + assert_equal "required", ceremony.options.fetch("userVerification") + assert_predicate ceremony.state, :present? + end + + private + + def assert_passkey_evidence(result) + assert_predicate result, :success? + assert_equal :passkey, result.evidence.sign_in_method + assert_equal "credential-id", result.evidence.credential_id + assert_equal "opaque-cook-handle", result.evidence.user_handle + assert_equal 4, result.evidence.signature_count + end + + def finish_authentication(ceremony) + Bitsmithy::Auth.finish_passkey_authentication( + credential: { "id" => "credential-id" }, + state: ceremony.state, + stored_credential: stored_credential + ) + end + + def stored_credential + Bitsmithy::Auth::Passkey::StoredCredential.new( + credential_id: "credential-id", + public_key: "public-key", + signature_count: 3, + user_handle: "opaque-cook-handle" + ) + end +end diff --git a/test/bitsmithy/auth/test_passkey_registration.rb b/test/bitsmithy/auth/test_passkey_registration.rb new file mode 100644 index 0000000..b22ea4c --- /dev/null +++ b/test/bitsmithy/auth/test_passkey_registration.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class TestPasskeyRegistration < Minitest::Test + VerifiedCredential = Data.define(:id, :public_key, :sign_count) + + class VerifyingRelyingParty + attr_reader :challenge, :user_verification + + def verify_registration(_credential, challenge, user_presence:, user_verification:) + @challenge = challenge + @user_verification = user_presence && user_verification + VerifiedCredential.new(id: "new-credential", public_key: "public-key", sign_count: 0) + end + end + + def setup + super + Bitsmithy::Auth.reset_config! + Bitsmithy::Auth.configure do |config| + config.envelope_key = "e" * 32 + config.passkey_origin = "https://cookmark.example" + config.passkey_relying_party_id = "cookmark.example" + config.passkey_relying_party_name = "Cookmark" + end + end + + def test_finishes_registration_with_persistence_ready_public_values + ceremony = start_registration + relying_party = VerifyingRelyingParty.new + Bitsmithy::Auth.config.passkey_relying_party = relying_party + + result = finish_registration(ceremony) + + assert_registered_credential(result) + assert_predicate relying_party, :user_verification + end + + def test_starts_discoverable_registration_with_local_user_verification + ceremony = start_registration + + assert_registration_options(ceremony.options) + assert_predicate ceremony.state, :present? + end + + private + + def assert_registered_credential(result) + credential = result.metadata.fetch(:credential) + + assert_predicate result, :success? + assert_equal "new-credential", credential.credential_id + assert_equal "public-key", credential.public_key + assert_equal 0, credential.signature_count + end + + def assert_registration_options(options) + assert_equal "required", options.dig("authenticatorSelection", "residentKey") + assert_equal "required", options.dig("authenticatorSelection", "userVerification") + assert_equal "none", options.fetch("attestation") + credential_ids = options.fetch("excludeCredentials").map { |credential| credential.fetch("id") } + + assert_equal ["existing-credential"], credential_ids + end + + def finish_registration(ceremony) + Bitsmithy::Auth.finish_passkey_registration( + credential: { "id" => "new-credential" }, + state: ceremony.state, + user_handle: "opaque-cook-handle" + ) + end + + def start_registration + Bitsmithy::Auth.start_passkey_registration( + user_handle: "opaque-cook-handle", + user_name: "alex@example.com", + exclude_credential_ids: ["existing-credential"] + ) + end +end diff --git a/test/bitsmithy/auth/test_rate_limiting.rb b/test/bitsmithy/auth/test_rate_limiting.rb deleted file mode 100644 index 7319f2b..0000000 --- a/test/bitsmithy/auth/test_rate_limiting.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -module Bitsmithy - module Auth - class TestRateLimiting < Minitest::Test - include ConfigHelper - - def test_send_code_returns_rate_limited_failure_after_max_attempts_per_phone - configure_for_tests - phone = "+12127363100" - - 5.times { Bitsmithy::Auth.send_code(phone) } - result = Bitsmithy::Auth.send_code(phone) - - assert_equal :rate_limited, result.error - end - - def test_rate_limit_is_isolated_per_phone - configure_for_tests - 5.times { Bitsmithy::Auth.send_code("+12127363100") } - - result = Bitsmithy::Auth.send_code("+14157361200") - - assert_predicate result, :success? - end - - def test_rate_limit_resets_after_window_expires - configure_for_tests - phone = "+12127363100" - 5.times { Bitsmithy::Auth.send_code(phone) } - future = Time.now + Bitsmithy::Auth::Config::DEFAULT_RATE_LIMIT[:window] + 1 - Time.stubs(:now).returns(future) - - result = Bitsmithy::Auth.send_code(phone) - - assert_predicate result, :success? - end - - def test_memory_store_is_mutex_protected_under_concurrent_load - store = Bitsmithy::Auth::Stores::MemoryStore.new - thread_count = 10 - per_thread = 100 - - threads = Array.new(thread_count) do - Thread.new { per_thread.times { store.increment("key", 3_600) } } - end - threads.each(&:join) - final_count_after_one_more = store.increment("key", 3_600) - - assert_equal (thread_count * per_thread) + 1, final_count_after_one_more - end - - def test_custom_store_is_used_when_set_on_config - custom_store = mock - custom_store.expects(:increment).at_least_once.returns(1) - Bitsmithy::Auth.configure do |c| - c.signing_key = "x" * 64 - c.rate_limit_store = custom_store - end - Bitsmithy::Auth.test_mode! - - Bitsmithy::Auth.send_code("+12127363100") - end - - def test_config_rate_limit_default_is_five_per_phone_per_hour - config = Bitsmithy::Auth::Config.new - - assert_equal({ per_phone: 5, window: 3_600 }, config.rate_limit) - end - end - end -end diff --git a/test/bitsmithy/auth/test_stateless_contract.rb b/test/bitsmithy/auth/test_stateless_contract.rb new file mode 100644 index 0000000..f35b61a --- /dev/null +++ b/test/bitsmithy/auth/test_stateless_contract.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class TestStatelessContract < Minitest::Test + def test_public_api_does_not_own_application_identity_or_sessions + legacy_methods = %i[ + send_code verify_code decode_token normalize_phone redact_phone + sign_in sign_out current_identity authenticated? require_authentication! + ] + + legacy_methods.each do |method_name| + refute_respond_to Bitsmithy::Auth, method_name + end + end +end diff --git a/test/bitsmithy/auth/test_test_mode_guard.rb b/test/bitsmithy/auth/test_test_mode_guard.rb deleted file mode 100644 index 22c4718..0000000 --- a/test/bitsmithy/auth/test_test_mode_guard.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -module Bitsmithy - module Auth - class TestTestModeGuard < Minitest::Test - include ConfigHelper - - def test_test_mode_raises_configuration_error_in_rails_production_env - Rails.env = RailsEnvStub.new(:production) - Bitsmithy::Auth.configure { |c| c.signing_key = "x" * 64 } - - assert_raises(Bitsmithy::Auth::ConfigurationError) do - Bitsmithy::Auth.test_mode! - end - end - - def test_test_mode_succeeds_in_rails_development_env - Rails.env = RailsEnvStub.new(:development) - Bitsmithy::Auth.configure { |c| c.signing_key = "x" * 64 } - - Bitsmithy::Auth.test_mode! - - assert_kind_of Bitsmithy::Auth::OTP::TestAdapter, Bitsmithy::Auth.config.otp_adapter - end - - def test_test_mode_raises_when_rails_is_not_defined - preserved_rails = Rails - Object.send(:remove_const, :Rails) - Bitsmithy::Auth.configure { |c| c.signing_key = "x" * 64 } - - assert_raises(Bitsmithy::Auth::ConfigurationError) do - Bitsmithy::Auth.test_mode! - end - ensure - Object.const_set(:Rails, preserved_rails) if preserved_rails - end - - def test_test_mode_error_message_names_the_allowed_environments - Rails.env = RailsEnvStub.new(:production) - Bitsmithy::Auth.configure { |c| c.signing_key = "x" * 64 } - - error = assert_raises(Bitsmithy::Auth::ConfigurationError) do - Bitsmithy::Auth.test_mode! - end - - assert_includes error.message, "test" - assert_includes error.message, "development" - end - end - end -end diff --git a/test/bitsmithy/auth/test_testing_support.rb b/test/bitsmithy/auth/test_testing_support.rb new file mode 100644 index 0000000..0846f76 --- /dev/null +++ b/test/bitsmithy/auth/test_testing_support.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class TestTestingSupport < Minitest::Test + def test_builds_deterministic_email_evidence + evidence = Bitsmithy::Auth::Testing.authentication_evidence( + :email, + environment: :test, + email: "Alex@Example.com", + authenticated_at: Time.utc(2026, 8, 28, 12, 0, 0) + ) + + assert_equal :email, evidence.sign_in_method + assert_equal "alex@example.com", evidence.email + end + + def test_refuses_to_build_evidence_in_production + assert_raises(Bitsmithy::Auth::ConfigurationError) do + Bitsmithy::Auth::Testing.authentication_evidence( + :email, + environment: :production, + email: "alex@example.com" + ) + end + end +end diff --git a/test/bitsmithy/auth/test_token_decoding.rb b/test/bitsmithy/auth/test_token_decoding.rb deleted file mode 100644 index 45c5321..0000000 --- a/test/bitsmithy/auth/test_token_decoding.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -module Bitsmithy - module Auth - class TestTokenDecoding < Minitest::Test - include ConfigHelper - - def test_decode_token_raises_invalid_token_for_garbage_input - configure_for_tests - - assert_raises(Bitsmithy::Auth::InvalidToken) do - Bitsmithy::Auth.decode_token("this is not a jwt") - end - end - - def test_decode_token_raises_invalid_token_for_token_signed_with_other_key - configure_for_tests - result = Bitsmithy::Auth.verify_code("+12127363100", "000000") - token = result.token - - Bitsmithy::Auth.config.signing_key = "a" * 64 - - assert_raises(Bitsmithy::Auth::InvalidToken) do - Bitsmithy::Auth.decode_token(token) - end - end - - def test_decode_token_raises_invalid_token_for_token_with_wrong_issuer - configure_for_tests - now = Time.now.to_i - foreign_token = JWT.encode( - { sub: "+12127363100", iat: now, exp: now + 86_400, iss: "some-other-issuer" }, - Bitsmithy::Auth.config.signing_key, - "HS256" - ) - - assert_raises(Bitsmithy::Auth::InvalidToken) do - Bitsmithy::Auth.decode_token(foreign_token) - end - end - - def test_decode_token_raises_invalid_token_for_expired_token - configure_for_tests - past = Time.now.to_i - 7_200 - expired_token = JWT.encode( - { sub: "+12127363100", iat: past, exp: past + 60, iss: Bitsmithy::Auth::Config::JWT_ISSUER }, - Bitsmithy::Auth.config.signing_key, - "HS256" - ) - - assert_raises(Bitsmithy::Auth::InvalidToken) do - Bitsmithy::Auth.decode_token(expired_token) - end - end - end - end -end diff --git a/test/bitsmithy/test_auth.rb b/test/bitsmithy/test_auth.rb index 762c31b..e3c4ccc 100644 --- a/test/bitsmithy/test_auth.rb +++ b/test/bitsmithy/test_auth.rb @@ -10,110 +10,21 @@ def test_that_it_has_a_version_number refute_nil ::Bitsmithy::Auth::VERSION end - def test_send_code_returns_a_success_result_in_test_mode - configure_for_tests - - result = Bitsmithy::Auth.send_code("+12127363100") - - assert_predicate result, :success? - end - - def test_verify_code_in_test_mode_issues_a_token_that_decodes_to_the_phone - configure_for_tests - - result = Bitsmithy::Auth.verify_code("+12127363100", "000000") - identity = Bitsmithy::Auth.decode_token(result.token) - - assert_equal "+12127363100", identity.phone - end - - def test_normalize_phone_returns_e164_for_us_number_with_country - result = Bitsmithy::Auth.normalize_phone("(212) 736-3100", country: "US") - - assert_equal "+12127363100", result - end - - def test_redact_phone_masks_the_middle_digits_of_an_e164_number - assert_equal "+1******3100", Bitsmithy::Auth.redact_phone("+12127363100") - end - - def test_token_expires_session_duration_seconds_after_issued_at - configure_for_tests - - result = Bitsmithy::Auth.verify_code("+12127363100", "000000") - identity = Bitsmithy::Auth.decode_token(result.token) - - assert_equal Bitsmithy::Auth::Config::DEFAULT_SESSION_DURATION, - (identity.expires_at - identity.issued_at).to_i - end - - def test_verify_code_returns_invalid_code_failure_for_wrong_code - configure_for_tests - - result = Bitsmithy::Auth.verify_code("+12127363100", "999999") - - assert_equal :invalid_code, result.error - end - - def test_send_code_returns_invalid_phone_number_failure_for_unparseable_input - configure_for_tests - - result = Bitsmithy::Auth.send_code("not a phone") - - assert_equal :invalid_phone_number, result.error - end - - def test_config_validate_raises_configuration_error_when_required_field_missing - config = Bitsmithy::Auth::Config.new - - assert_raises(Bitsmithy::Auth::ConfigurationError) do - config.validate! - end - end - - def test_config_validate_passes_when_all_required_fields_set + def test_config_validate_requires_the_stateless_host_contract config = Bitsmithy::Auth::Config.new - config.signing_key = "x" * 64 - config.twilio_account_sid = "ACtest" - config.twilio_auth_token = "secret" - config.twilio_verify_service_sid = "VAtest" - - assert_nil config.validate! - end - def test_invalid_phone_number_error_includes_raw_input_for_debuggability - raw_input = "+15555551234" + error = assert_raises(Bitsmithy::Auth::ConfigurationError) { config.validate! } - error = assert_raises(Bitsmithy::Auth::InvalidPhoneNumber) do - Bitsmithy::Auth.normalize_phone(raw_input, country: "US") - end - - assert_includes error.message, raw_input - end - - def test_verify_code_returns_invalid_phone_number_failure_for_unparseable_input - configure_for_tests - - result = Bitsmithy::Auth.verify_code("not a phone", "000000") - - assert_equal :invalid_phone_number, result.error + assert_match(/envelope_key/, error.message) + assert_match(/on_authenticated/, error.message) end - def test_test_mode_autofills_signing_key_when_unset - Rails.env = RailsEnvStub.new(:test) - Bitsmithy::Auth.test_mode! - - refute_nil Bitsmithy::Auth.config.signing_key - end - - def test_send_code_rate_limited_result_carries_normalised_phone - configure_for_tests - formatted_phone = "+1 (212) 736-3100" - 5.times { Bitsmithy::Auth.send_code(formatted_phone) } - - result = Bitsmithy::Auth.send_code(formatted_phone) + def test_config_validate_accepts_the_minimum_stateless_host_contract + config = Bitsmithy::Auth::Config.new + config.envelope_key = "e" * 32 + config.on_authenticated = ->(_evidence, _session) {} - assert_equal "+12127363100", result.phone + assert_predicate config, :validate! end end end diff --git a/test/dummy/app/controllers/test_controller.rb b/test/dummy/app/controllers/test_controller.rb index 25022d3..71b2999 100644 --- a/test/dummy/app/controllers/test_controller.rb +++ b/test/dummy/app/controllers/test_controller.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true class TestController < ApplicationController - include Bitsmithy::Auth::Controller - - before_action :require_authentication! - def index render plain: "OK" end + + def seed_session + session[:stale_value] = "old" + head :no_content + end end diff --git a/test/dummy/app/views/bitsmithy/auth/email_magic_links/error.html.erb b/test/dummy/app/views/bitsmithy/auth/email_magic_links/error.html.erb new file mode 100644 index 0000000..d2cdc3f --- /dev/null +++ b/test/dummy/app/views/bitsmithy/auth/email_magic_links/error.html.erb @@ -0,0 +1,2 @@ +

Magic Link unavailable

+ diff --git a/test/dummy/app/views/bitsmithy/auth/email_magic_links/exchange.html.erb b/test/dummy/app/views/bitsmithy/auth/email_magic_links/exchange.html.erb new file mode 100644 index 0000000..832d583 --- /dev/null +++ b/test/dummy/app/views/bitsmithy/auth/email_magic_links/exchange.html.erb @@ -0,0 +1,5 @@ +

Opening securely

+<%= form_with url: verify_email_magic_link_path, id: "magic-link-exchange" do %> + <%= hidden_field_tag :credential %> +<% end %> + diff --git a/test/dummy/app/views/bitsmithy/auth/email_magic_links/new.html.erb b/test/dummy/app/views/bitsmithy/auth/email_magic_links/new.html.erb new file mode 100644 index 0000000..6c94ac2 --- /dev/null +++ b/test/dummy/app/views/bitsmithy/auth/email_magic_links/new.html.erb @@ -0,0 +1,5 @@ +

Continue with email

+<%= form_with url: email_magic_links_path do %> + <%= email_field_tag :email, params[:email], required: true %> + <%= submit_tag "Email me a link" %> +<% end %> diff --git a/test/dummy/app/views/bitsmithy/auth/email_magic_links/sent.html.erb b/test/dummy/app/views/bitsmithy/auth/email_magic_links/sent.html.erb new file mode 100644 index 0000000..55ea03e --- /dev/null +++ b/test/dummy/app/views/bitsmithy/auth/email_magic_links/sent.html.erb @@ -0,0 +1 @@ +

Email sent

diff --git a/test/dummy/app/views/bitsmithy/auth/federated_authentications/error.html.erb b/test/dummy/app/views/bitsmithy/auth/federated_authentications/error.html.erb new file mode 100644 index 0000000..defde53 --- /dev/null +++ b/test/dummy/app/views/bitsmithy/auth/federated_authentications/error.html.erb @@ -0,0 +1,2 @@ +

Authentication unavailable

+

<%= @error %>

diff --git a/test/dummy/app/views/bitsmithy/auth/federated_authentications/new.html.erb b/test/dummy/app/views/bitsmithy/auth/federated_authentications/new.html.erb new file mode 100644 index 0000000..9c10f64 --- /dev/null +++ b/test/dummy/app/views/bitsmithy/auth/federated_authentications/new.html.erb @@ -0,0 +1,4 @@ +

Continue securely

+<%= link_to "Continue with Apple", apple_authentication_path %> +<%= link_to "Continue with Google", google_authentication_path %> +<%= link_to "Continue with email", new_email_magic_link_path %> diff --git a/test/dummy/app/views/bitsmithy/auth/sessions/edit.html.erb b/test/dummy/app/views/bitsmithy/auth/sessions/edit.html.erb deleted file mode 100644 index a2cc3e3..0000000 --- a/test/dummy/app/views/bitsmithy/auth/sessions/edit.html.erb +++ /dev/null @@ -1,8 +0,0 @@ -<% if @error.present? %> -
<%= @error %>
-<% end %> -<%= form_tag("/auth/verify") do %> - <%= label_tag :code, "Verification code" %> - <%= text_field_tag :code %> - <%= submit_tag "Verify" %> -<% end %> diff --git a/test/dummy/app/views/bitsmithy/auth/sessions/new.html.erb b/test/dummy/app/views/bitsmithy/auth/sessions/new.html.erb deleted file mode 100644 index 5f00fd3..0000000 --- a/test/dummy/app/views/bitsmithy/auth/sessions/new.html.erb +++ /dev/null @@ -1,8 +0,0 @@ -<% if @error.present? %> -
<%= @error %>
-<% end %> -<%= form_tag("/auth/send_code") do %> - <%= label_tag :phone, "Phone number" %> - <%= telephone_field_tag :phone %> - <%= submit_tag "Send code" %> -<% end %> diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb index 0602335..9f2545f 100644 --- a/test/dummy/config/application.rb +++ b/test/dummy/config/application.rb @@ -2,6 +2,7 @@ require "rails" require "action_controller/railtie" +require "action_mailer/railtie" require "bitsmithy/auth" @@ -12,6 +13,8 @@ class Application < Rails::Application config.eager_load = false config.hosts.clear config.action_controller.allow_forgery_protection = false + config.action_mailer.delivery_method = :test + config.active_job.queue_adapter = :test config.cache_store = :memory_store diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb index 87dcd18..4afd4d6 100644 --- a/test/dummy/config/routes.rb +++ b/test/dummy/config/routes.rb @@ -3,4 +3,5 @@ Rails.application.routes.draw do mount Bitsmithy::Auth::Engine => "/auth" get "/test" => "test#index" + post "/seed_session" => "test#seed_session" end diff --git a/test/engine/test_apple_authentication_flow.rb b/test/engine/test_apple_authentication_flow.rb new file mode 100644 index 0000000..0d2f2ac --- /dev/null +++ b/test/engine/test_apple_authentication_flow.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require_relative "../engine_helper" + +class TestAppleAuthenticationFlow < ActionDispatch::IntegrationTest + setup do + Bitsmithy::Auth.reset_config! + @provider = Object.new + @provider.define_singleton_method(:jwks) { @jwks } + @provider.define_singleton_method(:exchange) { |**| { "id_token" => @id_token } } + Bitsmithy::Auth.configure do |config| + config.envelope_key = "e" * 32 + config.apple_client_id = "com.cookmark.web" + config.apple_client_secret = "test-client-secret" + config.apple_provider_client = @provider + config.apple_redirect_uri = "https://cookmark.example/auth/apple/callback" + config.on_authenticated = lambda do |evidence, host_session| + host_session[:authenticated_email] = evidence.email + end + end + end + + test "Apple private relay evidence establishes the host-owned session" do + get "/auth/apple", params: { return_to: "/recipes" } + parameters = URI.decode_www_form(URI.parse(response.location).query).to_h + configure_id_token(parameters.fetch("nonce")) + + post "/auth/apple/callback", params: { + code: "authorization-code", + state: parameters.fetch("state") + } + + assert_redirected_to "/recipes" + assert_equal "relay@privaterelay.appleid.com", session[:authenticated_email] + end + + private + + def configure_id_token(nonce) + key = OpenSSL::PKey::RSA.generate(2048) + @provider.instance_variable_set( + :@jwks, + { "keys" => [JWT::JWK.new(key.public_key, kid: "test-key").export] } + ) + @provider.instance_variable_set( + :@id_token, + JWT.encode(id_token_claims(nonce), key, "RS256", kid: "test-key") + ) + end + + def id_token_claims(nonce) + { + iss: "https://appleid.apple.com", + aud: "com.cookmark.web", + sub: "apple-subject", + email: "relay@privaterelay.appleid.com", + email_verified: "true", + nonce: nonce + }.merge(token_times) + end + + def token_times + now = Time.now.utc.to_i + { iat: now, exp: now + 600 } + end +end diff --git a/test/engine/test_email_magic_link_flow.rb b/test/engine/test_email_magic_link_flow.rb new file mode 100644 index 0000000..7a4af18 --- /dev/null +++ b/test/engine/test_email_magic_link_flow.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require_relative "../engine_helper" + +class TestEmailMagicLinkFlow < ActionDispatch::IntegrationTest + setup do + Bitsmithy::Auth.reset_config! + @deliveries = [] + @claims = [] + Bitsmithy::Auth.configure do |config| + config.envelope_key = "e" * 32 + config.magic_link_delivery = ->(message) { @deliveries << message } + config.magic_link_redirect_uri = "https://cookmark.example/auth/email" + config.claim_magic_link = lambda do |replay_id| + @claims << replay_id + true + end + config.on_authenticated = lambda do |evidence, host_session| + host_session[:authenticated_email] = evidence.email + end + end + end + + test "successful email authentication resets the previous session" do + post "/seed_session" + + assert_equal "old", session[:stale_value] + + post "/auth/email_magic_links", params: { email: "alex@example.com" } + credential = URI.parse(@deliveries.fetch(0).url).fragment.delete_prefix("credential=") + + post "/auth/email_magic_links/verify", params: { credential: credential } + + assert_nil session[:stale_value] + assert_equal "alex@example.com", session[:authenticated_email] + end + + test "a replayed Magic Link cannot establish a host session" do + Bitsmithy::Auth.config.claim_magic_link = ->(_replay_id) { false } + post "/auth/email_magic_links", params: { email: "alex@example.com" } + credential = URI.parse(@deliveries.fetch(0).url).fragment.delete_prefix("credential=") + + post "/auth/email_magic_links/verify", params: { credential: credential } + + assert_response :unprocessable_content + assert_nil session[:authenticated_email] + assert_select "[role=alert]", text: I18n.t("bitsmithy_auth.errors.used_magic_link") + end + + test "host rate limiting prevents email delivery" do + Bitsmithy::Auth.config.allow_email_request = ->(_email, _request) { false } + + post "/auth/email_magic_links", params: { email: "alex@example.com" } + + assert_response :too_many_requests + assert_empty @deliveries + end + + test "email Magic Link evidence establishes the host-owned session" do + post "/auth/email_magic_links", params: { email: "Alex@Example.com" } + credential = URI.parse(@deliveries.fetch(0).url).fragment.delete_prefix("credential=") + + post "/auth/email_magic_links/verify", params: { credential: credential } + + assert_redirected_to "/" + assert_equal "alex@example.com", session[:authenticated_email] + assert_equal 1, @claims.length + end +end + +class TestDefaultMagicLinkDelivery < ActionDispatch::IntegrationTest + include ActionMailer::TestHelper + + setup do + Bitsmithy::Auth.reset_config! + Bitsmithy::Auth.configure do |config| + config.envelope_key = "e" * 32 + config.magic_link_sender = "Cookmark " + config.magic_link_redirect_uri = "https://cookmark.example/auth/email" + end + end + + test "RubyAuth enqueues the Magic Link email through Action Mailer" do + assert_enqueued_emails 1 do + post "/auth/email_magic_links", params: { email: "alex@example.com" } + end + end +end diff --git a/test/engine/test_email_magic_link_generator.rb b/test/engine/test_email_magic_link_generator.rb new file mode 100644 index 0000000..565d639 --- /dev/null +++ b/test/engine/test_email_magic_link_generator.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative "../engine_helper" +require "generators/bitsmithy/auth/install_generator" + +class EmailMagicLinkGeneratorTest < Rails::Generators::TestCase + destination File.expand_path("../tmp", __dir__) + setup :prepare_destination + setup :create_routes_file + tests Bitsmithy::Auth::Generators::InstallGenerator + + def test_creates_email_magic_link_host_templates + run_generator + assert_file "app/views/bitsmithy/auth/email_magic_links/new.html.erb" do |content| + assert_match(/email_magic_links_path/, content) + assert_match(/email_field_tag/, content) + end + assert_file "app/views/bitsmithy/auth/email_magic_links/exchange.html.erb" do |content| + assert_match(/verify_email_magic_link_path/, content) + assert_match(/location\.hash/, content) + end + end + + private + + def create_routes_file + root = self.class.destination_root + FileUtils.mkdir_p(File.join(root, "config")) + File.write(File.join(root, "config/routes.rb"), <<~RUBY) + Rails.application.routes.draw do + end + RUBY + end +end diff --git a/test/engine/test_google_authentication_flow.rb b/test/engine/test_google_authentication_flow.rb new file mode 100644 index 0000000..225d41f --- /dev/null +++ b/test/engine/test_google_authentication_flow.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require_relative "../engine_helper" + +class TestGoogleAuthenticationFlow < ActionDispatch::IntegrationTest + setup do + Bitsmithy::Auth.reset_config! + @provider = Object.new + @provider.define_singleton_method(:jwks) { @jwks } + @provider.define_singleton_method(:exchange) { |**| { "id_token" => @id_token } } + Bitsmithy::Auth.configure do |config| + config.envelope_key = "e" * 32 + config.google_client_id = "google-client-id" + config.google_provider_client = @provider + config.google_redirect_uri = "https://cookmark.example/auth/google/callback" + config.on_authenticated = lambda do |evidence, host_session| + host_session[:authenticated_email] = evidence.email + end + end + end + + test "Google evidence establishes the host-owned session" do + get "/auth/google", params: { return_to: "/recipes" } + parameters = URI.decode_www_form(URI.parse(response.location).query).to_h + configure_id_token(parameters.fetch("nonce")) + + get "/auth/google/callback", params: { + code: "authorization-code", + state: parameters.fetch("state") + } + + assert_redirected_to "/recipes" + assert_equal "alex@example.com", session[:authenticated_email] + end + + private + + def configure_id_token(nonce) + key = OpenSSL::PKey::RSA.generate(2048) + @provider.instance_variable_set( + :@jwks, + { "keys" => [JWT::JWK.new(key.public_key, kid: "test-key").export] } + ) + @provider.instance_variable_set( + :@id_token, + JWT.encode(id_token_claims(nonce), key, "RS256", kid: "test-key") + ) + end + + def id_token_claims(nonce) + { + iss: "https://accounts.google.com", + aud: "google-client-id", + sub: "google-subject", + email: "Alex@Example.com", + email_verified: true, + nonce: nonce + }.merge(token_times) + end + + def token_times + now = Time.now.utc.to_i + { iat: now, exp: now + 600 } + end +end diff --git a/test/engine/test_install_generator.rb b/test/engine/test_install_generator.rb index 7536647..2bf8166 100644 --- a/test/engine/test_install_generator.rb +++ b/test/engine/test_install_generator.rb @@ -2,93 +2,37 @@ require_relative "../engine_helper" require "generators/bitsmithy/auth/install_generator" +require "tmpdir" class InstallGeneratorTest < Rails::Generators::TestCase - destination File.expand_path("../tmp", __dir__) + destination File.join(Dir.tmpdir, "bitsmithy-auth-generator-#{Process.pid}") setup :prepare_destination setup :create_routes_file + teardown :remove_destination tests Bitsmithy::Auth::Generators::InstallGenerator - def create_routes_file - root = self.class.destination_root - FileUtils.mkdir_p(File.join(root, "config")) - File.write(File.join(root, "config/routes.rb"), <<~RUBY) - Rails.application.routes.draw do - get "/up" => "health#show" - end - RUBY - end - - test "generator class exists" do - assert(defined?(Bitsmithy::Auth::Generators::InstallGenerator)) - end - - test "generator inherits from Rails::Generators::Base" do - assert_equal Rails::Generators::Base, - Bitsmithy::Auth::Generators::InstallGenerator.superclass - end - - test "generator has a source_root pointing to templates directory" do - path = Bitsmithy::Auth::Generators::InstallGenerator.source_root - - assert path.end_with?("templates") - assert File.directory?(path) - end - - test "creates initializer with production configuration" do - run_generator - assert_file "config/initializers/bitsmithy_auth.rb" do |content| - assert_match(/signing_key.*ENV\.fetch/, content) - assert_match(/if Rails\.env\.production\?/, content) - assert_match(/require.*twilio_adapter/, content) - assert_match(/twilio_account_sid.*ENV\.fetch/, content) - end - end - - test "creates initializer with test mode guard" do - run_generator - assert_file "config/initializers/bitsmithy_auth.rb" do |content| - assert_match(/else\s*\n\s*Bitsmithy::Auth\.test_mode!/, content) - end - end - - test "creates initializer with commented optional overrides" do + test "creates initializer for the stateless host contract" do run_generator assert_file "config/initializers/bitsmithy_auth.rb" do |content| - assert_match(/# c\.after_sign_in_path/, content) - assert_match(/# c\.after_sign_out_path/, content) - assert_match(/# c\.sign_in_path/, content) - assert_match(/# c\.on_verified/, content) + assert_match(/envelope_key/, content) + assert_match(/google_client_id/, content) + assert_match(/apple_client_id/, content) + assert_match(/passkey_relying_party_id/, content) + assert_match(/on_authenticated/, content) + refute_match(/Twilio|phone|signing_key/, content) end end - test "creates phone form template" do + test "creates the provider choice template" do run_generator - assert_file "app/views/bitsmithy/auth/sessions/new.html.erb" do |content| - assert_match(/send_code_path/, content) - assert_match(/@error/, content) - assert_match(/telephone_field_tag/, content) + assert_file "app/views/bitsmithy/auth/federated_authentications/new.html.erb" do |content| + assert_match(/apple_authentication_path/, content) + assert_match(/google_authentication_path/, content) + assert_match(/new_email_magic_link_path/, content) end end - test "creates code form template" do - run_generator - assert_file "app/views/bitsmithy/auth/sessions/edit.html.erb" do |content| - assert_match(/verify_path/, content) - assert_match(/@phone/, content) - assert_match(/@error/, content) - assert_match(/text_field_tag.*:code/, content) - end - end - - test "inserts mount line into routes" do - run_generator - assert_file "config/routes.rb" do |content| - assert_match(/mount Bitsmithy::Auth::Engine/, content) - end - end - - test "does not duplicate mount line on second run" do + test "inserts the mount line once" do 2.times { run_generator } assert_file "config/routes.rb" do |content| assert_equal 1, content.scan("mount Bitsmithy::Auth::Engine").size @@ -97,7 +41,8 @@ def create_routes_file test "does not overwrite existing files without force" do run_generator - existing_content = File.read(File.join(destination_root, "config/initializers/bitsmithy_auth.rb")) + initializer = File.join(destination_root, "config/initializers/bitsmithy_auth.rb") + existing_content = File.read(initializer) run_generator assert_file "config/initializers/bitsmithy_auth.rb", existing_content @@ -105,11 +50,29 @@ def create_routes_file test "force flag overwrites existing files" do run_generator - File.write(File.join(destination_root, "config/initializers/bitsmithy_auth.rb"), "# custom content\n") + initializer = File.join(destination_root, "config/initializers/bitsmithy_auth.rb") + File.write(initializer, "# custom content\n") run_generator ["--force"] + assert_file "config/initializers/bitsmithy_auth.rb" do |content| - assert_match(/signing_key.*ENV/, content) + assert_match(/envelope_key/, content) refute_match(/custom content/, content) end end + + private + + def remove_destination + FileUtils.rm_rf(self.class.destination_root) + end + + def create_routes_file + root = self.class.destination_root + FileUtils.mkdir_p(File.join(root, "config")) + File.write(File.join(root, "config/routes.rb"), <<~RUBY) + Rails.application.routes.draw do + get "/up" => "health#show" + end + RUBY + end end diff --git a/test/engine/test_locale_smoke.rb b/test/engine/test_locale_smoke.rb index 216869d..6c38a34 100644 --- a/test/engine/test_locale_smoke.rb +++ b/test/engine/test_locale_smoke.rb @@ -5,9 +5,9 @@ class LocaleSmokeTest < ActiveSupport::TestCase test "every engine-surfaced error symbol has a non-missing en translation" do error_symbols = %i[ - invalid_phone_number - rate_limited - invalid_code + apple_unavailable expired_magic_link google_unavailable + invalid_apple_authentication invalid_email invalid_google_authentication + invalid_magic_link rate_limited used_magic_link ] error_symbols.each do |symbol| diff --git a/test/engine/test_passkey_authentication_flow.rb b/test/engine/test_passkey_authentication_flow.rb new file mode 100644 index 0000000..b531793 --- /dev/null +++ b/test/engine/test_passkey_authentication_flow.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require_relative "../engine_helper" + +class TestPasskeyAuthenticationFlow < ActionDispatch::IntegrationTest + FakeOptions = Data.define(:challenge) do + def as_json + { challenge: challenge, allowCredentials: [], userVerification: "required" } + end + end + VerifiedCredential = Data.define(:id, :sign_count) + + class FakeRelyingParty + def options_for_authentication(**) + FakeOptions.new(challenge: "authentication-challenge") + end + + def verify_authentication(*) + VerifiedCredential.new(id: "credential-id", sign_count: 4) + end + end + + setup do + Bitsmithy::Auth.reset_config! + @counter_updates = [] + Bitsmithy::Auth.configure do |config| + config.envelope_key = "e" * 32 + config.passkey_relying_party = FakeRelyingParty.new + config.find_passkey_credential = ->(_credential_id) { stored_credential } + config.update_passkey_credential = ->(credential_id, count) { @counter_updates << [credential_id, count] } + config.on_authenticated = lambda do |evidence, host_session| + host_session[:passkey_user_handle] = evidence.user_handle + end + end + end + + test "an unknown Passkey fails without establishing a session" do + Bitsmithy::Auth.config.find_passkey_credential = ->(_credential_id) {} + get "/auth/passkeys/authentication" + ceremony = response.parsed_body + + post "/auth/passkeys/authentication", params: { + credential: { id: "unknown-credential" }, + state: ceremony.fetch("state") + } + + assert_response :unprocessable_content + assert_nil session[:passkey_user_handle] + assert_empty @counter_updates + end + + test "validated Passkey evidence establishes the host-owned session" do + get "/auth/passkeys/authentication", params: { return_to: "/recipes" } + ceremony = response.parsed_body + + post "/auth/passkeys/authentication", params: { + credential: { id: "credential-id" }, + state: ceremony.fetch("state") + } + + assert_redirected_to "/recipes" + assert_equal "opaque-cook-handle", session[:passkey_user_handle] + assert_equal [["credential-id", 4]], @counter_updates + end + + private + + def stored_credential + Bitsmithy::Auth::Passkey::StoredCredential.new( + credential_id: "credential-id", + public_key: "public-key", + signature_count: 3, + user_handle: "opaque-cook-handle" + ) + end +end diff --git a/test/engine/test_passkey_registration_flow.rb b/test/engine/test_passkey_registration_flow.rb new file mode 100644 index 0000000..e70faf7 --- /dev/null +++ b/test/engine/test_passkey_registration_flow.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require_relative "../engine_helper" + +class TestPasskeyRegistrationFlow < ActionDispatch::IntegrationTest + FakeOptions = Data.define(:challenge) do + def as_json + { challenge: challenge } + end + end + VerifiedCredential = Data.define(:id, :public_key, :sign_count) + + class FakeRelyingParty + def options_for_registration(**) + FakeOptions.new(challenge: "registration-challenge") + end + + def verify_registration(*) + VerifiedCredential.new(id: "credential-id", public_key: "public-key", sign_count: 0) + end + end + + setup do + Bitsmithy::Auth.reset_config! + @stored_credentials = [] + Bitsmithy::Auth.configure do |config| + config.envelope_key = "e" * 32 + config.passkey_relying_party = FakeRelyingParty.new + config.passkey_registration_context = lambda do |_request, _session| + { user_handle: "cook-handle", user_name: "alex@example.com", exclude_credential_ids: [] } + end + config.store_passkey_credential = lambda do |credential, name| + @stored_credentials << [credential, name] + end + end + end + + test "the host can refuse Passkey registration" do + Bitsmithy::Auth.config.passkey_registration_context = ->(_request, _session) {} + + get "/auth/passkeys/registration" + + assert_response :forbidden + assert_empty @stored_credentials + end + + test "the host authorizes and stores a validated Passkey registration" do + get "/auth/passkeys/registration" + ceremony = response.parsed_body + + post "/auth/passkeys/registration", params: { + credential: { id: "credential-id" }, + state: ceremony.fetch("state"), + name: "Kitchen laptop" + } + + assert_response :created + assert_equal "credential-id", @stored_credentials.fetch(0).fetch(0).credential_id + assert_equal "Kitchen laptop", @stored_credentials.fetch(0).fetch(1) + end +end diff --git a/test/engine/test_rails_cache_store.rb b/test/engine/test_rails_cache_store.rb deleted file mode 100644 index 150a887..0000000 --- a/test/engine/test_rails_cache_store.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -require_relative "../engine_helper" - -class RailsCacheStoreTest < ActiveSupport::TestCase - setup do - @store = Bitsmithy::Auth::Stores::RailsCacheStore.new(Rails.cache) - end - - test "increment returns 1 on first call" do - assert_equal 1, @store.increment("counter:a", 60) - end - - test "increment returns cumulative count on subsequent calls" do - @store.increment("counter:b", 60) - @store.increment("counter:b", 60) - - assert_equal 3, @store.increment("counter:b", 60) - end - - test "different keys are independent" do - @store.increment("counter:c", 60) - @store.increment("counter:c", 60) - - assert_equal 1, @store.increment("counter:d", 60) - end -end diff --git a/test/engine/test_sessions_flow.rb b/test/engine/test_sessions_flow.rb deleted file mode 100644 index f0f980b..0000000 --- a/test/engine/test_sessions_flow.rb +++ /dev/null @@ -1,167 +0,0 @@ -# frozen_string_literal: true - -require_relative "../engine_helper" - -class TestSessionsFlow < ActionDispatch::IntegrationTest # rubocop:disable Metrics/ClassLength - setup do - Bitsmithy::Auth.reset_config! - Bitsmithy::Auth.configure { |c| c.signing_key = "x" * 64 } - Bitsmithy::Auth.test_mode! - end - - test "GET sign-in route renders the phone entry template" do - get "/auth/sign_in" - - assert_response :ok - assert_select "input[type='tel']" - end - - test "POST send_code stores pending phone and redirects to code form" do - post "/auth/send_code", params: { phone: "+12125551234" } - - assert_redirected_to "/auth/code" - assert_equal "+12125551234", session[:bitsmithy_auth_pending_phone] - end - - test "POST send_code with invalid phone re-renders form with error" do - post "/auth/send_code", params: { phone: "not-a-phone" } - - assert_response :ok - assert_select "input[type='tel']" - assert_select "div.error", I18n.t("bitsmithy_auth.errors.invalid_phone_number") - end - - test "POST send_code when rate-limited re-renders form with rate limit error" do - phone = "+12125551234" - - # Replace the rate limiter with one that always raises RateLimited - rate_limiter = Object.new - rate_limiter.define_singleton_method(:check!) { |_| raise Bitsmithy::Auth::RateLimited } - Bitsmithy::Auth.stubs(:rate_limiter).returns(rate_limiter) - - post "/auth/send_code", params: { phone: phone } - - assert_response :ok - assert_select "input[type='tel']" - assert_select "div.error", I18n.t("bitsmithy_auth.errors.rate_limited") - end - - test "POST verify with magic code signs in and redirects to root" do - post "/auth/send_code", params: { phone: "+12125551234" } - - assert_redirected_to "/auth/code" - follow_redirect! - - post "/auth/verify", params: { code: "000000" } - - assert_redirected_to "/" - assert_predicate session[:bitsmithy_auth_token], :present? - assert_nil session[:bitsmithy_auth_pending_phone] - end - - test "POST verify with wrong code re-renders code form with error and retains pending phone" do - post "/auth/send_code", params: { phone: "+12125551234" } - follow_redirect! - - post "/auth/verify", params: { code: "wrong" } - - assert_response :ok - assert_select "div.error", I18n.t("bitsmithy_auth.errors.invalid_code") - assert_equal "+12125551234", session[:bitsmithy_auth_pending_phone] - end - - test "POST verify redirects to configured after_sign_in_path" do - old_path = Bitsmithy::Auth.config.after_sign_in_path - Bitsmithy::Auth.config.after_sign_in_path = "/dashboard" - - post "/auth/send_code", params: { phone: "+12125551234" } - follow_redirect! - post "/auth/verify", params: { code: "000000" } - - assert_redirected_to "/dashboard" - ensure - Bitsmithy::Auth.config.after_sign_in_path = old_path - end - - test "DELETE sign_out clears session and redirects to root" do - post "/auth/send_code", params: { phone: "+12125551234" } - follow_redirect! - post "/auth/verify", params: { code: "000000" } - - assert_predicate session[:bitsmithy_auth_token], :present? - - delete "/auth/sign_out" - - assert_redirected_to "/" - assert_nil session[:bitsmithy_auth_token] - end - - test "DELETE sign_out redirects to configured after_sign_out_path" do - old_path = Bitsmithy::Auth.config.after_sign_out_path - Bitsmithy::Auth.config.after_sign_out_path = "/goodbye" - - post "/auth/send_code", params: { phone: "+12125551234" } - follow_redirect! - post "/auth/verify", params: { code: "000000" } - - assert_predicate session[:bitsmithy_auth_token], :present? - - delete "/auth/sign_out" - - assert_redirected_to "/goodbye" - ensure - Bitsmithy::Auth.config.after_sign_out_path = old_path - end - - test "on_verified callback fires on successful verify and receives the identity" do - verified_identity = nil - Bitsmithy::Auth.config.on_verified = ->(identity) { verified_identity = identity } - - post "/auth/send_code", params: { phone: "+12125551234" } - follow_redirect! - post "/auth/verify", params: { code: "000000" } - - assert_redirected_to "/" - assert_equal "+12125551234", verified_identity.phone - end - - test "on_verified callback does not fire on failed verify" do - callback_fired = false - Bitsmithy::Auth.config.on_verified = ->(_identity) { callback_fired = true } - - post "/auth/send_code", params: { phone: "+12125551234" } - follow_redirect! - post "/auth/verify", params: { code: "wrong" } - - assert_response :ok - assert_not callback_fired, "on_verified should not fire on wrong code" - end - - test "require_authentication! redirects unauthenticated to sign_in_path" do - get "/test" - - assert_redirected_to Bitsmithy::Auth::Engine.routes.url_helpers.sign_in_path - end - - test "require_authentication! allows authenticated requests through" do - post "/auth/send_code", params: { phone: "+12125551234" } - follow_redirect! - post "/auth/verify", params: { code: "000000" } - - get "/test" - - assert_response :ok - assert_equal "OK", response.body - end - - test "require_authentication! redirects to configured sign_in_path override" do - old_path = Bitsmithy::Auth.config.sign_in_path - Bitsmithy::Auth.config.sign_in_path = "/custom-sign-in" - - get "/test" - - assert_redirected_to "/custom-sign-in" - ensure - Bitsmithy::Auth.config.sign_in_path = old_path - end -end diff --git a/test/support/config_helper.rb b/test/support/config_helper.rb index 3bb1420..65d9abb 100644 --- a/test/support/config_helper.rb +++ b/test/support/config_helper.rb @@ -6,11 +6,4 @@ def setup Bitsmithy::Auth.reset_config! Rails.env = RailsEnvStub.new(:test) end - - def configure_for_tests - Bitsmithy::Auth.configure do |c| - c.signing_key = "x" * 64 - end - Bitsmithy::Auth.test_mode! - end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 95b68ef..77d5269 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -2,14 +2,7 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__) -# Silence twilio-ruby's deprecation and SDK-generated style warnings -# (CGI removal in Ruby 4.0, method redefinitions, mismatched indentation). -# All other warnings — including any in our own code — still surface. require "warning" -Warning.ignore(%r{/gems/twilio-ruby-}) - -# Load ActionController BEFORE the gem so the conditional require for -# Bitsmithy::Auth::Controller fires inside lib/bitsmithy/auth.rb. require "action_controller" # Load every gem source file so test files don't need to track their own