# AGS Account SSO — How it works

This is the SSO contract between `account.allgifted.com` and every
downstream app (vocab, math, forma, …). It explains the runtime flow
end-to-end, the JWT shape, the database tables on both sides, and the
exact steps for adding a new consumer app.

## The user flow (no jargon)

1. The learner is on a marketing site or app launcher and clicks
   "Sign in".
2. They land at `https://account.allgifted.com/login`. They type
   an email or phone. We send a 6-digit OTP.
3. They paste the code. We sign them in **once**.
4. They see a dashboard with cards for Vocab, Math, Forma. They click
   one.
5. We hand them a one-use ticket (JWT) and bounce them to that app's
   callback URL.
6. The app validates the ticket, creates a local session, and the
   learner is on the home screen, signed in. No second password
   prompt, no second OTP.

## The technical flow

```
┌──────────────┐                                                  ┌────────────────────┐
│  Browser     │                                                  │ account.allgifted  │
│  (learner)   │                                                  │ .com  (Laravel)    │
└──────┬───────┘                                                  └─────────┬──────────┘
       │                                                                    │
       │  GET /login                                                        │
       ├──────────────────────────────────────────────────────────────────►│
       │                                                                    │
       │  POST /login  contact=foo@bar.com                                  │
       ├──────────────────────────────────────────────────────────────────►│
       │                                                                    │ OtpService::request()
       │                                                                    │ → email "Your code: 123456"
       │  GET /verify                                                       │
       │◄──────────────────────────────────────────────────────────────────┤
       │                                                                    │
       │  POST /verify code=123456                                          │
       ├──────────────────────────────────────────────────────────────────►│
       │                                                                    │ OtpService::verify()
       │                                                                    │ → Auth::login() → web session cookie
       │  302 /  (dashboard)                                                │
       │◄──────────────────────────────────────────────────────────────────┤
       │                                                                    │
       │  GET /apps/vocab/launch                                            │
       ├──────────────────────────────────────────────────────────────────►│
       │                                                                    │ DashboardController::launch()
       │                                                                    │   ↓
       │                                                                    │ SsoTokenService::issueForApp(user, vocab)
       │                                                                    │   → JWT signed with client_apps.jwt_secret
       │                                                                    │     payload: {iss, aud=vocab, sub, email, phone,
       │                                                                    │               name, kudos_global, is_premium,
       │                                                                    │               iat, nbf, exp (60s), jti}
       │  302 https://vocab.allgifted.com/sso/callback?sso_token=<JWT>      │
       │◄──────────────────────────────────────────────────────────────────┤
       │
       │  (Flutter web SPA loads — fallback to index.html)
       │
       │  Flutter main.dart: reads Uri.base.queryParameters['sso_token']
       │                                                                    ┌────────────────────┐
       │  POST https://vocabapi.allgifted.com/api/sso/exchange              │ vocabapi.allgifted │
       │  { sso_token: "<JWT>" }                                            │ .com  (Laravel)    │
       ├──────────────────────────────────────────────────────────────────►└─────────┬──────────┘
       │                                                                              │
       │                                                                              │ SsoController::exchange()
       │                                                                              │   ↓
       │                                                                              │ JWT::decode($jwt, SSO_JWT_SECRET, HS256)
       │                                                                              │ Verify iss=account.allgifted.com
       │                                                                              │ Verify aud=vocab
       │                                                                              │ Upsert local user
       │                                                                              │   match order: external_id → email → phone
       │                                                                              │ Ensure school_users membership
       │                                                                              │ Create Sanctum token
       │  200 { token: "13|...", user, school }                                       │
       │◄────────────────────────────────────────────────────────────────────────────┤
       │
       │  Flutter persists Sanctum token to shared_preferences
       │  Splash flows to home screen — learner is signed in
       │
```

## The JWT

HS256, 60-second TTL. Each downstream app holds its own per-app
secret in `account.client_apps.jwt_secret` (server side) and in its
own `.env` as `SSO_JWT_SECRET` (consumer side).

```json
{
  "iss":          "account.allgifted.com",
  "aud":          "vocab",                  // matches client_apps.slug
  "sub":          "1",                       // account.users.id (as string)
  "email":        "pamelaliusm@gmail.com",
  "phone":        null,
  "name":         "Pamela Lim",
  "kudos_global": 134,
  "is_premium":   true,
  "iat":          1748254800,
  "nbf":          1748254800,
  "exp":          1748254860,                // 60s after iat
  "jti":          "a3f2b8…"                  // random 16 bytes, future replay guard
}
```

**Why HS256 and not RS256?** Per-app shared secrets means a leak in
one consumer app can never forge tokens for another. We operate every
consumer app ourselves; there's no third-party client to issue keys to.
If we ever add a third-party app, RS256 + JWKS endpoint is the natural
upgrade.

## Tables

### `account.allgifted.com` (master)

| Table | Purpose |
|---|---|
| `users` | Canonical AGS identity (id, name, email, phone, kudos_global, is_premium, role). One row per human. |
| `otp_codes` | Pending + consumed OTP attempts (hashed codes). |
| `client_apps` | Registered consumer apps: `slug`, `name`, `jwt_secret` (96-char hex), `launch_url`, `redirect_uri`, `use_magic_link`, `magic_link_endpoint`, `magic_link_token`. |
| `personal_access_tokens` | Sanctum tokens for any future API consumers. |
| `sessions` | Web sessions for the dashboard (Auth::login uses cookies). |

### Consumer app (vocab example)

| Column on `users` | Purpose |
|---|---|
| `external_id` | The account user id (`sub` claim). The bridge between apps. |
| `email`, `phone`, `name` | Mirrored from JWT claims. Local copies so the app keeps working if account is briefly unreachable. |
| `is_premium` | Mirrored from JWT claim. Refreshed on every successful exchange. |

`SSO_JWT_SECRET` lives in `.env`. Rotation procedure below.

## Endpoints

### Account side (`account.allgifted.com`)

| Method | Path | Auth | Purpose |
|---|---|---|---|
| `GET`  | `/login`                | guest | Renders the OTP request form |
| `POST` | `/login`                | guest | Sends OTP via email or SMS |
| `GET`  | `/verify`               | guest | Renders the 6-digit code form |
| `POST` | `/verify`               | guest | Verifies the code, logs the user in, redirects |
| `GET`  | `/`                     | auth  | Dashboard (shows Vocab/Math/Forma cards) |
| `GET`  | `/apps/{slug}/launch`   | auth  | Issues a JWT for `{slug}`, redirects to its `launch_url?sso_token=…` |
| `POST` | `/logout`               | auth  | Ends the account-side session |

### Consumer app side (vocab example)

| Method | Path | Auth | Purpose |
|---|---|---|---|
| `POST` | `/api/sso/exchange`     | none  | Body: `{sso_token}`. Validates JWT, upserts user, returns `{token, user, school}` |

## Adding a new client app

5-minute checklist.

1. **Register the app on the account side.** Either via the Filament
   admin (TODO — direct DB for now) or a one-liner:

   ```php
   \App\Models\ClientApp::create([
       'slug'         => 'new-app',
       'name'         => 'AGS New App',
       'description'  => 'What this app does.',
       'icon'         => '🆕',
       'color'        => '#3BA9F4',
       'jwt_secret'   => bin2hex(random_bytes(48)),
       'launch_url'   => 'https://new-app.allgifted.com/sso/callback',
       'redirect_uri' => 'https://new-app.allgifted.com/sso/callback',
       'use_magic_link' => false,
       'display_order' => 40,
   ]);
   ```

2. **Copy the per-app `jwt_secret`** into the new app's `.env` as
   `SSO_JWT_SECRET`. Same value on both sides — that's what HS256 means.

   ```bash
   # Server side
   mysql -uroot -p"$DBPASS" account -Nse \
     "SELECT jwt_secret FROM client_apps WHERE slug='new-app';"
   # → paste into /var/www/html/new-app/.env as SSO_JWT_SECRET=...
   ```

3. **Add the consumer endpoint.** Copy vocab's pattern at
   `app/Http/Controllers/Api/SsoController.php` —
   `POST /api/sso/exchange` validates the JWT (audience match,
   issuer match, signature with `config('services.sso.jwt_secret')`),
   upserts the local user by `external_id` → `email` → `phone`, mints
   a local Sanctum token.

4. **Add the consumer URL handler.** In the frontend's bootstrap,
   read `?sso_token=` from the URL on launch, POST it to
   `/api/sso/exchange`, persist the returned local token. Vocab's
   pattern: `mobile/lib/main.dart` + `mobile/lib/api_client.dart::ssoExchange`.

5. **Verify the round-trip.** From the server:
   ```bash
   # Mint a JWT for an account user + your new app
   php artisan tinker --execute='
     $u = \App\Models\User::firstOrFail();
     $a = \App\Models\ClientApp::where("slug","new-app")->firstOrFail();
     echo app(\App\Services\Sso\SsoTokenService::class)->issueForApp($u, $a);
   '
   # Then curl:
   curl -X POST https://new-app.allgifted.com/api/sso/exchange \
     -H "Content-Type: application/json" \
     -d '{"sso_token":"<PASTE>"}'
   ```
   Expect 200 with `{token, user}`.

## Forma (and other third-party LMS) — magic-link orchestrator

Forma is a 3rd-party LMS with its own auth. We don't ship our JWT to
it — we use Forma's own auth machinery via a server-to-server
"magic link" call. Set on `client_apps`:

| Column | Value |
|---|---|
| `use_magic_link` | `true` |
| `magic_link_endpoint` | `https://highschool.allgifted.com/api/sso/issue` (Forma plugin endpoint) |
| `magic_link_token` | Bearer for that endpoint (issued by the Forma admin) |
| `launch_url` | the URL Forma serves you back (or just `https://highschool.allgifted.com/`) |

When the learner clicks the Forma card, `DashboardController::launch`
calls the magic-link endpoint server-to-server, gets back a one-shot
URL, and redirects the user. Forma sets its own session.

This branch is stubbed today (returns 501); implementation lands in
slice 2 once a Forma plugin exists on their side.

## JWT secret rotation

Routine: rotate per-app every 90 days, or any time you suspect a leak.

```bash
# 1. Generate a new secret + update the account DB
NEW_SECRET=$(openssl rand -hex 48)
mysql -uroot -p"$DBPASS" account -e \
  "UPDATE client_apps SET jwt_secret='${NEW_SECRET}' WHERE slug='vocab';"

# 2. Update the consumer .env + reload its config
sed -i "s|^SSO_JWT_SECRET=.*|SSO_JWT_SECRET=${NEW_SECRET}|" /var/www/html/vocabapi/.env
cd /var/www/html/vocabapi && php artisan config:cache && systemctl reload apache2
```

In-flight tokens (≤60s old) signed with the OLD secret will fail
validation — they're short-lived enough that this is rarely a problem.
For zero-downtime rotation, accept BOTH old + new for one TTL window:
add a second secret column to `client_apps` and have
`SsoTokenService::verifyForApp()` try the new one first, falling back
to old. Defer that until you need it.

## Privacy & threat model

- **No PII in JWT logs.** The account service logs OTP attempts and
  webhook events but never logs the issued JWT body. The consumer apps
  receive the JWT but don't persist it past exchange.
- **Per-app secret = blast-radius containment.** A compromise in one
  consumer app's `.env` lets the attacker forge tokens for THAT app
  only. They cannot forge tokens for vocab using math's secret.
- **60-second TTL.** Tokens are good for one redirect. Replay risk is
  bounded by the TTL; `jti` is reserved for one-time-use enforcement
  if we ever need it (cache the JTI for TTL seconds, reject re-use).
- **No user identity to Anthropic / Stripe / etc.** SSO is purely
  identity propagation between AGS apps. External services never see
  the SSO JWT.

## Operational notes

- **HTTPS only.** The HTTP vhost for `account.allgifted.com` redirects
  to HTTPS (certbot-managed). All consumer SSO endpoints are also
  HTTPS-only. Plain HTTP would expose JWTs in transit.
- **DNS.** Wildcard records aren't used — each consumer subdomain
  (`vocab.`, `math.`, `account.`, `highschool.`) gets an explicit
  A record. The account app's record is the bottleneck for adding
  new SSO consumers; the consumer app's record is the bottleneck
  for it being callable at all.
- **Backup.** `account.client_apps.jwt_secret` is the most sensitive
  column in the database. The full `account` schema goes into the
  nightly `mysqldump → /var/backups/mysql/account-<ts>.sql.gz`.
- **Local dev.** The account app runs at
  `http://127.0.0.1:8004` (or whatever you set). Set each consumer's
  `SSO_JWT_SECRET` to whatever you seed locally; pointing the consumer
  at the local account is just changing the `SSO_ACCOUNT_URL` env.

## Why the design ended up this way

A few decisions you might disagree with, written down so future-you
remembers the reasoning.

- **OTP only, no passwords.** Drives every consumer's gate from one
  primitive: a verified contact. Eliminates password reset flows,
  bcrypt config, breach reporting. Trade-off: SMS deliverability in
  some carriers. Email always works; SMS is best-effort.
- **HS256 per-app vs OIDC.** OIDC is the textbook answer but adds a
  discovery doc, JWKS endpoint, key rotation tooling, refresh-token
  semantics, and a client lib per consumer. For 3 apps you operate,
  HS256 buys you the same security with one HTTP call and ~40 lines
  of Dart. Promote to OIDC when you onboard a third-party consumer.
- **Account as orchestrator for Forma, not Forma as a JWT consumer.**
  Forma is third-party with its own auth. Asking them to validate our
  JWT requires a plugin in their codebase. The magic-link pattern uses
  Forma's existing primitives — we send them a server-to-server
  "this user is good, give me a session URL" call, they hand us a URL,
  we redirect the user there.
- **`external_id` on consumer apps, not a Foreign Key.** Consumer
  apps run on different servers (today, the same droplet; tomorrow,
  maybe not). A FK would couple them to the account DB. `external_id`
  is just a string the consumer trusts because the JWT claimed it.
  If account is offline, the consumer keeps serving users who already
  signed in once.
