Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 59 additions & 57 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
97 changes: 75 additions & 22 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading