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
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ concurrency:

jobs:
test:
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
name: Test (Ruby ${{ matrix.ruby }})
strategy:
fail-fast: false
Expand All @@ -35,7 +35,7 @@ jobs:
run: bundle exec rake test

rubocop:
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
name: Rubocop
steps:
- uses: actions/checkout@v6
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
/pkg/
/spec/reports/
/tmp/
log/
20 changes: 15 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
## [Unreleased]

### Added

- **Mountable Rails engine** (`Bitsmithy::Auth::Engine`) — isolated-namespace
engine that owns the full sign-in flow. Mount with one line in routes;
host app provides two view templates (Phone form, code form).
- **Engine actions** — new, create (send code), edit (code form), update (verify
code), destroy (sign out).
- **Configuration additions** — `after_sign_in_path`, `after_sign_out_path`,
`sign_in_path`, `on_verified` callback.
- **`require_authentication!`** — opt-in before-action guard on the Controller
concern. Redirects unauthenticated requests to the configured sign-in path.
Reverses the prior deliberate omission documented in v0.1.0.
- **Shipped `en` locale** — default messages for `invalid_phone_number`,
`rate_limited`, and `invalid_code` under `bitsmithy_auth.errors`.

## [0.1.0] - 2026-05-28

Initial release. Phone-number OTP authentication primitive for Ruby applications.
Expand Down Expand Up @@ -43,8 +58,3 @@ Six ADRs in `docs/adr/` document the load-bearing choices:
- 0005 — Twilio Verify as OTP backend
- 0006 — Pattern A cross-language naming convention

### Not in v0.1.0 (planned for v0.2.0+)

Mountable Rails engine with default sign-in views; install generator;
Redis-backed rate-limit Store; voice / WhatsApp OTP channels; email OTP;
TOTP / authenticator-app codes.
8 changes: 8 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ _Avoid_: secret, key, JWT secret.
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.

**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.

**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.

## Example dialogue

> **Dev:** When a phone is verified, do we mark the User active?
Expand Down
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ gem "rubocop-rake", require: false

group :test do
gem "actionpack", "~> 8.0"
gem "cgi"
gem "railties", "~> 8.0"
end
21 changes: 21 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ GEM
base64 (0.3.0)
bigdecimal (4.1.2)
builder (3.3.0)
cgi (0.5.1)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
Expand Down Expand Up @@ -98,13 +99,24 @@ GEM
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.4.2)
rdoc (7.2.0)
Expand Down Expand Up @@ -139,6 +151,7 @@ GEM
ruby2_keywords (0.0.5)
securerandom (0.4.1)
stringio (3.2.0)
thor (1.5.0)
tsort (0.2.0)
twilio-ruby (7.10.7)
faraday (>= 2.0, < 3.0)
Expand All @@ -152,16 +165,19 @@ GEM
uri (1.1.1)
useragent (0.16.11)
warning (1.6.0)
zeitwerk (2.8.2)

PLATFORMS
x86_64-linux

DEPENDENCIES
actionpack (~> 8.0)
bitsmithy-auth!
cgi
irb
minitest (~> 5.16)
mocha (~> 2.0)
railties (~> 8.0)
rake (~> 13.0)
rubocop (~> 1.21)
rubocop-minitest
Expand All @@ -178,6 +194,7 @@ CHECKSUMS
bitsmithy-auth (0.1.0)
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
bundler (4.0.12) sha256=7f8b757d28dfb636e7b24fba2344ac6dd13b5b24f4b46d62573d483f211825ac
cgi (0.5.1) sha256=e93fcafc69b8a934fe1e6146121fa35430efa8b4a4047c4893764067036f18e9
concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
Expand Down Expand Up @@ -211,8 +228,10 @@ CHECKSUMS
rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2
rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d
rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89
railties (8.1.3) sha256=913eb0e0cb520aac687ffd74916bd726d48fa21f47833c6292576ef6a286de22
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192
Expand All @@ -226,6 +245,7 @@ CHECKSUMS
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
twilio-ruby (7.10.7) sha256=1551b05c221eafe678e63e6776ac46211a13e6742d865c53d124af24aabed6a9
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
Expand All @@ -234,6 +254,7 @@ CHECKSUMS
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844
warning (1.6.0) sha256=a49cdfae19fb77d19afff2efbe45f8ab759e9cd25b4e4ce2c79dbaf46bdb6c9e
zeitwerk (2.8.2) sha256=7212a61311083c604184b1ea2574b9aa05cd14f855a0841c06985cabe9181d12

BUNDLED WITH
4.0.12
154 changes: 104 additions & 50 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,21 @@ Backed by [Twilio Verify](https://www.twilio.com/docs/verify) — Twilio owns OT

## Status — v0.1.0

| In v0.1.0 | Deferred to v0.2.0 |
|---|---|
| Framework-agnostic core API | Mountable Rails engine + default views |
| Rails `Controller` concern (optional) | Install generator |
| Twilio Verify production adapter | Redis-backed rate-limit store |
| Per-Phone rate limiting (in-memory store) | Voice / WhatsApp channels |
| PII-redacting `redact_phone` helper | Email OTP / TOTP |
| `test_mode!` with Rails-env guard | |
| 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 |

### Future (not yet shipped)

Install generator, Redis-backed rate-limit store, voice / WhatsApp channels, email OTP / TOTP.

## Installation

Expand Down Expand Up @@ -109,63 +116,108 @@ You get:
| `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` |

Deliberately **no `require_authentication!`** — host apps own redirect semantics. The four-line pattern:
### 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.

| 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) |

**`app/views/bitsmithy/auth/sessions/edit.html.erb`** — Code-entry form.

| 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) |

### Configuration

Configure the engine in the same initializer you already use:

```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)

# Optional overrides (defaults shown):
# c.session_duration = 86_400
# c.after_sign_in_path = "/"
# c.after_sign_out_path = "/"
# c.on_verified = ->(identity) { ... }
end
```

| 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 |

### Using `require_authentication!`

The engine Controller concern provides an opt-in `require_authentication!` guard. Add it to any controller to protect actions:

```ruby
class ApplicationController < ActionController::Base
include Bitsmithy::Auth::Controller
before_action :require_authentication!

private

def require_authentication!
redirect_to sign_in_path unless authenticated?
end
end
```

## Sign-in controller (host-app code)
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.

### Mapping the verified Identity downstream

Roughly thirty lines. Host apps write their own — the gem does not ship a default sign-in flow in v0.1.0.
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
class SessionsController < ApplicationController
skip_before_action :require_authentication!

def new
end

def create
result = Bitsmithy::Auth.send_code(params[:phone])
if result.success?
session[:pending_phone] = result.phone
render :verify
else
flash.now[:error] = error_message_for(result.error)
render :new
end
end

def verify
result = Bitsmithy::Auth.verify_code(session[:pending_phone], params[:code])
if result.success?
sign_in(token: result.token)
session.delete(:pending_phone)
redirect_to root_path
else
flash.now[:error] = "That code didn't match — try again."
render :verify
end
end

def destroy
sign_out
redirect_to root_path
end
# 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
Expand All @@ -180,6 +232,8 @@ Then in tests, `send_code` always succeeds and `verify_code(phone, "000000")` al

## 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.<symbol>`. 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 |
Expand Down
Loading