4ce2df0a2b
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 18s
CI / backend (pull_request) Successful in 52s
CI / frontend (pull_request) Successful in 15s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 55s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 54s
71 lines
4.1 KiB
Markdown
71 lines
4.1 KiB
Markdown
# 02 — Auth & Sign-in (Part 2)
|
|
|
|
**OAuth is the login.** No passwords. A user's identity is the set of provider accounts
|
|
linked to them; any one can authenticate the session. Identity key is **`(provider, sub)`**
|
|
— never email (emails change; `sub` is stable, and the same email can exist on Google *and*
|
|
Microsoft as distinct accounts).
|
|
|
|
## Provider-selection sign-in (first-time)
|
|
```
|
|
[ Choose how to sign in ]
|
|
▸ Continue with Google ▸ Continue with Microsoft ( ▸ IMAP — future )
|
|
```
|
|
1. User picks a provider → redirect to provider OAuth (**PKCE**, `state`, `nonce`).
|
|
2. Callback → exchange code → `GetIdentityAsync` returns `(provider, sub, email, name)`.
|
|
3. **Resolve:** look up `accounts (provider, sub)`.
|
|
- **No match →** first-time. Create `user` (**first user ever = Admin**, otherwise `Member`
|
|
if `system_settings.registration_open`, else reject) + `account` (`is_login_identity=true`)
|
|
+ encrypted `provider_tokens`. Start session.
|
|
- **Match →** existing user. Refresh tokens, start session.
|
|
4. Kick off the account's initial sync.
|
|
|
|
## Adding another account later (linking) — the security-critical flow
|
|
The user is **already authenticated**. "Add account" → provider OAuth in **link mode**:
|
|
- Callback identity `(provider, sub)`:
|
|
- **Unlinked →** attach a new `account` (mailbox) to the **current** user. ✅
|
|
- **Already linked to *this* user →** no-op / "already connected."
|
|
- **Already linked to *another* user →** **blocked** by the unique `(provider, provider_account_id)`
|
|
constraint + explicit check → error "This mailbox is connected to a different InboxIntel
|
|
user." **This is the anti-hijack guarantee** — you can only link an identity you can
|
|
authenticate *and* that no one else owns.
|
|
- A single user can hold **N accounts** across Google/Microsoft/IMAP; each can also serve as a
|
|
login identity (any of them signs you into the same user).
|
|
|
|
## Switching
|
|
- **Switch mailbox (same user):** an **account switcher** changes the active mailbox context
|
|
(or "All accounts" unified view). No re-auth — it's all one user. Search can scope to one
|
|
account or span all.
|
|
- **Switch user (different person):** full sign-out → sign-in. Optional "fast switch" could
|
|
hold multiple sessions, but for a small team, explicit re-auth is simplest and safest.
|
|
|
|
## Sessions
|
|
- **Opaque server-side session** (`sessions` table) referenced by an **HttpOnly · Secure ·
|
|
SameSite=Lax** cookie. **Provider tokens are never exposed to the browser.**
|
|
- Rotate session id on login (anti-fixation); **idle (e.g., 7d) + absolute (e.g., 30d)**
|
|
expiry; revoke on logout; **"sign out everywhere"** and **admin revoke** delete session rows.
|
|
- CSRF: SameSite + anti-CSRF token on state-changing requests.
|
|
|
|
## Token lifecycle
|
|
- Stored **encrypted at rest** (Data Protection); decrypted only in-memory for API calls.
|
|
- **Refresh** on expiry/401 via `ITokenStore` → re-encrypt + persist; failure flips account to
|
|
**`ReauthNeeded`** (banner + "Reconnect" CTA, see [07](07-ux-flows.md)) — sync/AI for that
|
|
account pause, the rest of the app is unaffected.
|
|
|
|
## Provider OAuth specifics
|
|
| | Google | Microsoft (Graph) |
|
|
|--|--------|-------------------|
|
|
| Endpoint | accounts.google.com | login.microsoftonline.com (`common`) |
|
|
| Scopes | `openid email profile gmail.readonly gmail.modify` | `openid email profile offline_access Mail.Read Mail.ReadWrite` |
|
|
| Identity | `sub` (+ verified email) | `oid`/`sub` (+ email) |
|
|
| Refresh | refresh_token (offline) | refresh_token (`offline_access`) |
|
|
| Redirect | `/signin/google` | `/signin/microsoft` |
|
|
- **Least privilege:** request read/modify only (no send today — matches current posture).
|
|
Extra scopes are added per-feature with consent, never up-front.
|
|
|
|
## Edge cases
|
|
- **Same email, two providers** → two distinct accounts (identity is `sub`), unless the user
|
|
links both to one InboxIntel user.
|
|
- **Provider disabled by admin flag** (`provider.microsoft=false`) → hide it on the picker;
|
|
existing accounts of that provider pause sync and show a notice.
|
|
- **Reused browser / stale cookie** → session validated server-side each request; revoked/expired → re-auth.
|