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