Skip to content

Commit c3e59d3

Browse files
committed
docs: add architecture documentation covering OAuth 2.1 flows
1 parent b5429b5 commit c3e59d3

1 file changed

Lines changed: 277 additions & 0 deletions

File tree

docs/architecture.md

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
# Architecture
2+
3+
How the auth app and planner app work together to authenticate users.
4+
5+
## Overview
6+
7+
The auth app (`auth.codebar.io`) is an OAuth 2.1 / OIDC provider built with
8+
[Better Auth](https://better-auth.com). It serves login, profile, and logout
9+
pages, and delegates application authentication to the **planner app**
10+
(`codebar.io`) which delegates to the auth app via a custom OmniAuth strategy.
11+
12+
```
13+
┌─────────────────────┐ OAuth 2.1 + OIDC ┌─────────────────────┐
14+
│ Planner App │◄───────────────────────►│ Auth App │
15+
│ (codebar.io) │ authorization code │ (auth.codebar.io) │
16+
│ │ PKCE │ │
17+
│ OmniAuth::Codebar │ token exchange │ Better Auth │
18+
│ Rails session │ JWKS verification │ oauthProvider │
19+
│ │ │ GitHub OAuth │
20+
│ │ │ Magic Link │
21+
└─────────────────────┘ └─────────────────────┘
22+
```
23+
24+
The auth app offers two ways to authenticate:
25+
26+
- **GitHub OAuth** — user signs in with their GitHub account
27+
- **Magic Link** — user enters their email and receives a one-time sign-in link
28+
29+
Both follow the same outer OAuth 2.1 flow. They differ only in how the user
30+
authenticates on the auth app's login page.
31+
32+
## Architecture Diagram
33+
34+
```mermaid
35+
graph TB
36+
subgraph Browser["Browser"]
37+
U[User]
38+
end
39+
40+
subgraph Planner["codebar.io"]
41+
R[Rails Router]
42+
OM[OmniAuth::Codebar]
43+
AC[AuthServicesController]
44+
RS[Rails Session]
45+
46+
OM -- "callback_phase" --> AC
47+
end
48+
49+
subgraph Auth["auth.codebar.io"]
50+
BA[Better Auth]
51+
GP[GitHub OAuth Plugin]
52+
ML[Magic Link Plugin]
53+
OP[oauthProvider Plugin]
54+
JWKS[JWKS Endpoint]
55+
LOGIN[Login Page]
56+
end
57+
58+
subgraph External["External"]
59+
GH[GitHub OAuth API]
60+
SG[SendGrid]
61+
end
62+
63+
U -- "/auth/codebar" --> R
64+
R -- "request_phase" --> OM
65+
OM -- "redirect to authorize" --> U
66+
U -- "authorize + callback" --> BA
67+
BA -- "login page" --> LOGIN
68+
LOGIN -- "GitHub OAuth" --> GP
69+
GP -- "OAuth flow" --> GH
70+
LOGIN -- "Magic Link" --> ML
71+
ML -- "send email" --> SG
72+
BA -- "redirect with code" --> U
73+
U -- "/auth/codebar/callback?code=..." --> R
74+
R -- "callback_phase" --> OM
75+
OM -- "POST /api/auth/oauth2/token" --> BA
76+
OM -- "GET /api/auth/jwks" --> JWKS
77+
OM -- "call_app!" --> AC
78+
AC --> RS
79+
```
80+
81+
## Flow A: GitHub OAuth
82+
83+
The user clicks "Sign in with codebar" on the planner, authenticates via GitHub
84+
on the auth app, and gets redirected back.
85+
86+
```mermaid
87+
sequenceDiagram
88+
actor User
89+
participant Planner as Planner (codebar.io)
90+
participant Auth as Auth App (auth.codebar.io)
91+
participant GitHub as GitHub
92+
93+
Note over User,Planner: User visits /auth/codebar
94+
User->>Planner: GET /auth/codebar
95+
Planner->>Planner: request_phase: generate PKCE verifier, state
96+
Planner->>User: 302 Redirect to authorize endpoint
97+
Note right of User: ?client_id=planner&response_type=code<br/>code_challenge=...&code_challenge_method=S256
98+
99+
User->>Auth: GET /api/auth/oauth2/authorize
100+
Auth->>Auth: No session → show login page
101+
Auth-->>User: Login page (GitHub + Magic Link)
102+
103+
User->>Auth: Click "Sign in with GitHub"
104+
Auth->>GitHub: Redirect to GitHub OAuth
105+
User->>GitHub: Sign in (if needed)
106+
GitHub-->>Auth: OAuth callback with code
107+
Auth->>Auth: GitHub auth → create session
108+
Auth->>User: 302 Redirect to planner callback
109+
Note right of User: ?code=authorization_code&state=...
110+
111+
User->>Planner: GET /auth/codebar/callback?code=...&state=...
112+
Planner->>Planner: callback_phase: verify state
113+
Planner->>Auth: POST /api/auth/oauth2/token
114+
Note right of Planner: grant_type=authorization_code<br/>code_verifier=...<br/>client_id=planner
115+
Auth-->>Planner: { access_token, id_token, expires_in }
116+
117+
Planner->>Auth: GET /api/auth/jwks
118+
Auth-->>Planner: { keys: [ JWK ] }
119+
120+
Planner->>Planner: verify JWT signature, iss, aud
121+
Planner->>Planner: build omniauth.auth hash
122+
Planner->>Planner: find or create member
123+
Planner-->>User: Signed in (session cookie)
124+
```
125+
126+
## Flow B: Magic Link
127+
128+
The user clicks "Sign in with codebar", enters their email on the auth app,
129+
clicks the magic link, and completes the OAuth flow.
130+
131+
```mermaid
132+
sequenceDiagram
133+
actor User
134+
participant Planner as Planner (codebar.io)
135+
participant Auth as Auth App (auth.codebar.io)
136+
participant Email as SendGrid
137+
138+
Note over User,Planner: User visits /auth/codebar
139+
User->>Planner: GET /auth/codebar
140+
Planner->>Planner: request_phase: generate PKCE verifier, state
141+
Planner->>User: 302 Redirect to authorize endpoint
142+
143+
User->>Auth: GET /api/auth/oauth2/authorize
144+
Auth-->>User: Login page (GitHub + Magic Link)
145+
146+
User->>Auth: Enter email → "Send Magic Link"
147+
Auth->>Email: POST send magic link email
148+
Email-->>User: Email with sign-in link
149+
150+
User->>Auth: Click magic link in email
151+
Auth->>Auth: Verify token → create session
152+
Auth->>User: 302 Redirect to planner callback
153+
Note right of User: ?code=authorization_code&state=...
154+
155+
User->>Planner: GET /auth/codebar/callback?code=...&state=...
156+
157+
Planner->>Planner: callback_phase: verify state
158+
Planner->>Auth: POST /api/auth/oauth2/token
159+
Auth-->>Planner: { access_token, id_token, expires_in }
160+
161+
Planner->>Auth: GET /api/auth/jwks
162+
Auth-->>Planner: { keys: [ JWK ] }
163+
164+
Planner->>Planner: verify JWT signature, iss, aud
165+
Planner->>Planner: build omniauth.auth hash
166+
Planner->>Planner: find or create member
167+
Planner-->>User: Signed in (session cookie)
168+
```
169+
170+
The flows converge at the token exchange. The only difference is how the user
171+
authenticates on the auth app.
172+
173+
## Key Components
174+
175+
### Auth App (`codebar/auth`)
176+
177+
- **Better Auth** instance with plugins: `jwt`, `oauthProvider`, `magicLink`,
178+
`admin`
179+
- **oauthProvider plugin** — issues OAuth 2.1 authorization codes and tokens.
180+
Configured with PKCE required, `planner` as the only valid audience, and
181+
`allowDynamicClientRegistration: false`.
182+
- **Login page** (`/login`) — offers "Sign in with GitHub" and "Send Magic
183+
Link" options, served during the OAuth authorize phase when no session
184+
exists.
185+
- **JWKS endpoint** (`/api/auth/jwks`) — serves public keys for JWT signature
186+
verification, fetched and cached by the planner.
187+
- **Seed client** (`src/app/db/seed-client.js`) — registers the `planner`
188+
OAuth client in the database via raw SQL. Runs during every deploy (release
189+
phase) for idempotency.
190+
191+
### Planner App (`codebar/planner`)
192+
193+
- **OmniAuth custom strategy** (`lib/omniauth/strategies/codebar.rb`)
194+
— implements the OAuth 2.1 client with PKCE:
195+
- **Request phase** — redirects to the auth app's authorize endpoint with
196+
PKCE challenge, state, and OIDC scopes
197+
- **Callback phase** — verifies state, exchanges the code for tokens,
198+
verifies the JWT against JWKS, builds the `omniauth.auth` hash
199+
- **Route** (`/auth/codebar`) — triggers the OmniAuth request phase
200+
- **Route** (`/auth/codebar/callback`) — triggers the callback phase, which
201+
calls `call_app!``AuthServicesController#create`
202+
- **Controller** (`app/controllers/auth_services_controller.rb`) — creates or
203+
finds a `Member` and establishes a Rails session
204+
205+
## Environment Layout
206+
207+
| App | Domain | Heroku App |
208+
| -------------------- | ------------------------------- | ------------------------------------------------- |
209+
| Auth (production) | `auth.codebar.io` | `codebar-auth-production` |
210+
| Planner (production) | `codebar.io` | `codebar-auth-production` (branch: `heroku/main`) |
211+
| Planner (staging) | `codebar-staging.herokuapp.com` | `codebar-staging` |
212+
213+
There is no staging auth app. Auth changes are deployed directly to production
214+
via the `heroku/main` branch.
215+
216+
### Environment Variables
217+
218+
**Auth app:**
219+
220+
| Variable | Purpose |
221+
| ----------------------- | ----------------------------------------------------- |
222+
| `DATABASE_URL` | PostgreSQL connection string |
223+
| `GITHUB_CLIENT_ID` | GitHub OAuth app client ID |
224+
| `GITHUB_CLIENT_SECRET` | GitHub OAuth app client secret |
225+
| `SENDGRID_API_KEY` | API key for magic link email delivery |
226+
| `PLANNER_REDIRECT_URIS` | Comma-separated list of allowed callback URIs |
227+
| `CODEBAR_AUTH_URL` | Self-referential base URL (`https://auth.codebar.io`) |
228+
229+
**Planner app:**
230+
231+
| Variable | Purpose |
232+
| ------------------ | --------------------------------------------- |
233+
| `CODEBAR_AUTH_URL` | Auth app base URL (`https://auth.codebar.io`) |
234+
| `CODEBAR_AUDIENCE` | JWT audience (`planner`) |
235+
236+
## Operational Details
237+
238+
### OAuth Client Seeding
239+
240+
The `planner` OAuth client is registered directly in the database rather than
241+
through Better Auth's admin API. The seed runs during the Heroku release phase
242+
via `scripts/migrate.js`:
243+
244+
```
245+
heroku-release.sh → node scripts/migrate.js → seedPlannerClient()
246+
```
247+
248+
The client is configured with:
249+
250+
- **Public client** (no client secret) — PKCE is required for all flows
251+
- **Multiple redirect URIs** — one per environment, configured via
252+
`PLANNER_REDIRECT_URIS`
253+
- **Idempotent** — uses `ON CONFLICT DO UPDATE` so redeploys refresh the row
254+
255+
### Known Issues & Workarounds
256+
257+
**Cloudflare User-Agent filtering.** The auth app sits behind Cloudflare,
258+
which rejects HTTP requests with the default Ruby User-Agent (`User-Agent:
259+
Ruby`). The planner's OmniAuth strategy sets a descriptive User-Agent on every
260+
outgoing request (`Codebar Planner/1.0`).
261+
262+
**Cross-site state cookie check.** Better Auth v1.6.20 added a signed cookie
263+
check on the OAuth callback that fails in cross-site flows (planner → auth →
264+
planner). The auth app disables this check with
265+
`account.skipStateCookieCheck: true`.
266+
267+
**Redirect URIs as JSON array.** The `redirectUris` column in the database is
268+
`jsonb`. The seed client uses `$1::jsonb` with `JSON.stringify()` to store
269+
the redirect URIs as a proper JSON array.
270+
271+
## Deployment
272+
273+
Auth app deploys are triggered by pushes to `heroku/main` (production).
274+
The `scripts/heroku-release.sh` release phase runs `scripts/migrate.js` which
275+
migrates the database schema and seeds the OAuth client.
276+
277+
See [deployment.md](./deployment.md) for manual deploy and rollback procedures.

0 commit comments

Comments
 (0)