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