# Browser Login: Cookie + Session Flow

**Audience:** developers and operators who need to understand what happens
between a user clicking "Login" and the registry returning their first
authenticated page. This doc is provider-agnostic — the actual OAuth handshake
with Entra / Okta / Keycloak / Cognito / Auth0 happens earlier and is
documented in [idp-provider-support.md](idp-provider-support.md). What follows
here is everything that happens inside the platform once the IdP returns the
user's identity.

**Related docs:**
- [authentication-design.md](authentication-design.md) — the broader auth
  architecture (humans, JWTs, M2M).
- [idp-provider-support.md](idp-provider-support.md) — provider-specific
  configuration and flows.
- [cookie-security-design.md](cookie-security-design.md) — cross-subdomain
  cookie domain settings (still current; this doc layers on top of it).
- [session-flow-jwt-bearer.md](session-flow-jwt-bearer.md) — the equivalent
  doc for the JWT/Bearer M2M path.

---

## 1. The shape of a login

```
                    +-------------------+
   1. /login        |                   |
+----------------->| Registry frontend |
   user clicks      |                   |
                    +---------+---------+
                              |  302 Redirect
                              v
                    +-------------------+
   2. /oauth2/login |                   |
+----------------->|    auth-server    |
                    |                   |
                    +---------+---------+
                              |  302 Redirect
                              v
                    +-------------------+
   3. authorize     |  IdP (Entra /     |
+----------------->|  Okta / Keycloak /|
                    |  Cognito / Auth0)|
                    +---------+---------+
                              |  302 Redirect (with code)
                              v
                    +-------------------+
   4. /oauth2/      |                   |
      callback     |    auth-server    |
+----------------->|                   |
                    +---------+---------+
                              |  Set-Cookie: mcp_gateway_session=<id>
                              |  302 Redirect to registry /
                              v
                    +-------------------+
   5. authenticated |                   |
      page request |    Registry      |
                    |    (FastAPI)     |
                    +---------+---------+
                              |  reads cookie
                              v
                    +-------------------+
                    | session_store    |
                    | (MongoDB)        |
                    +-------------------+
```

The single observable thing the user's browser keeps from this flow is one
HTTP-only signed cookie named `mcp_gateway_session` (configurable via
`SESSION_COOKIE_NAME`). Everything else lives server-side.

---

## 2. What the browser cookie contains

After [PR #1042](https://github.com/agentic-community/mcp-gateway-registry/pull/1042)
the cookie payload is **only an opaque, signed `session_id`** — a 64-character
hex string identifying a row in the server-side `oauth_sessions_<namespace>`
collection.

### 2.1 Wire format

```
mcp_gateway_session = itsdangerous_signed("<64-hex-session-id>")
```

- Signed with `URLSafeTimedSerializer(SECRET_KEY)` — see
  [registry/auth/dependencies.py:17](../../registry/auth/dependencies.py#L17)
  and [auth_server/session_store.py:106](../../auth_server/session_store.py#L106).
- `session_id` is generated by `secrets.token_hex(32)` (32 bytes of CSPRNG output).
- The signature embeds the issue timestamp; the cookie is rejected if older
  than `SESSION_MAX_AGE_SECONDS`.
- Cookie is `HttpOnly`, `SameSite=Lax`, and `Secure=true` whenever the original
  request was HTTPS (auth-server reads `X-Forwarded-Proto` to detect
  load-balancer-terminated TLS). Domain attribute set from `SESSION_COOKIE_DOMAIN`.

### 2.2 What the cookie does NOT contain

It does NOT carry username, groups, scopes, email, or `id_token`. Those used
to live in the cookie pre-PR-#1042 (which is why Entra users with hundreds of
AD groups could not log in — the cookie blew the 4 KB browser limit). They
now live in the server-side session record.

### 2.3 Cookie size

A regression guard in
[tests/auth_server/unit/test_server.py](../../tests/auth_server/unit/test_server.py)
asserts the cookie value stays under **512 bytes** regardless of how many
groups the user has. The assertion is the contract: anyone who tries to put
inline user data back into the cookie will fail this test.

---

## 3. The server-side session record

The full session payload lives in MongoDB / DocumentDB. Collection name:
`oauth_sessions_<namespace>` where `<namespace>` is the deployment's
configured DocumentDB database (multi-tenant deployments use this for
isolation).

### 3.1 Document shape

```javascript
{
  session_id:        "<64-hex string>",       // Primary key, unique
  username:          "alice@example.com",      // From IdP claim
  email:             "alice@example.com",
  name:              "Alice Example",
  groups:            ["devs", "admins"],       // From IdP groups claim or Graph
  provider:          "entra",                  // Which IdP authenticated
  auth_method:       "oauth2",
  created_at:        ISODate(...),
  expires_at:        ISODate(...),             // TTL: doc auto-deleted at this time
  id_token_encrypted: <BinData>                // 12-byte nonce || AES-GCM ciphertext
}
```

### 3.2 Indexes

Two indexes, both created idempotently on first write
([auth_server/session_store.py:80-98](../../auth_server/session_store.py#L80-L98)):

| Index | Fields | Properties | Purpose |
|-------|--------|------------|---------|
| `ux_session_id` | `session_id ASC` | unique | Primary-key lookups; collision prevention. |
| `ttl_expires_at` | `expires_at ASC` | `expireAfterSeconds=0` | TTL — Mongo auto-deletes the document at the time stored in `expires_at`. Cleanup runs every ~60s. |

### 3.3 At-rest encryption of `id_token`

The `id_token_encrypted` field is the only encrypted-at-rest field. Username,
groups, and email are stored as-is — they were already client-visible in the
old in-cookie payload, so encrypting them adds operational cost (debugging,
audit) for no real threat-model gain. The `id_token` is different: it's a
bearer credential and is sensitive enough to warrant defense against a
DB-snapshot leak.

Encryption: AES-GCM, key derived from `SECRET_KEY` via HKDF-SHA256 with the
fixed info string `mcp-gateway-session-id-token-encryption`. Domain separator
ensures this key is distinct from the cookie-signing key derived from the
same `SECRET_KEY`. See
[registry/auth/session_crypto.py](../../registry/auth/session_crypto.py).

Per-record nonce: 96-bit random from `secrets.token_bytes(12)`. AES-GCM
nonce-uniqueness is guaranteed under any reasonable session volume.

`SECRET_KEY` rotation invalidates all stored encrypted `id_token`s; rotation
requires a process restart of all auth-server and registry replicas (the
AES-GCM cipher is cached in a process-wide singleton).

### 3.4 Lifecycle

| Event | What happens |
|-------|--------------|
| OAuth callback succeeds | auth-server inserts the document, gets `session_id`, returns a `Set-Cookie` to the browser. |
| Authenticated request | registry resolves the cookie -> `session_id` -> `find_one({session_id})`. One indexed PK lookup per request. |
| TTL expiry | Mongo's TTL monitor deletes the document automatically. Subsequent reads return None -> client redirected to `/login`. |
| Logout | registry calls `delete_session(session_id)`, then clears the cookie. The pre-delete order closes the cookie-replay window. See [registry/auth/routes.py:217-226](../../registry/auth/routes.py#L217-L226). |
| Suspected leak | Operator drops the entire `oauth_sessions_<namespace>` collection — every session invalidated at once. See [docs/operations/incident-response.md](../operations/incident-response.md). |

---

## 4. Provider-agnostic vs provider-specific

The cookie + session-store design above is **identical for every IdP**. The
only thing that varies between providers is how `groups` get into the
session document.

| Provider | How `groups` get populated |
|----------|----------------------------|
| Cognito | `cognito:groups` claim from the ID token. |
| Keycloak | `groups` claim from the access token (configured via Keycloak group mapper). |
| Okta | `groups` claim from the access token (requires Okta group claim configured at the app or authorization-server level). |
| Auth0 | Custom claim namespaced under `https://mcp-gateway/groups` (configurable via `AUTH0_GROUPS_CLAIM`). Auth0 requires a Rule/Action to inject this claim. |
| **Entra ID** | Inline `groups` claim **OR** the auth-server falls back to Microsoft Graph `/me/memberOf` when the ID token signals overage (`hasgroups: true` or `_claim_names.groups` present). Capped at 1000 group IDs. See [auth_server/providers/entra.py:421-481](../../auth_server/providers/entra.py#L421-L481). |

Once the auth-server has `groups` in hand, the rest of the flow — session
write, cookie set, registry resolve, scope derivation — is provider-blind.

The provider name is recorded on the session document in the `provider`
field. Logout uses this to send the user back to the right IdP's logout
endpoint with `id_token_hint` for proper SSO termination.

---

## 5. How the registry sees you on every request

Two paths reach `_derive_user_context`, the single source of truth for
"is this user an admin and what can they see?"
([registry/auth/dependencies.py:453-518](../../registry/auth/dependencies.py#L453-L518)).
Cookie-based browser requests go through the cookie path. Header-based
requests (typically nginx-proxied API calls with a JWT) go through the
header path; that path is documented in
[session-flow-jwt-bearer.md](session-flow-jwt-bearer.md).

### 5.1 Cookie path: `enhanced_auth`

```
Browser request
    |
    | Cookie: mcp_gateway_session=<signed-session-id>
    v
+----------------------------+
| FastAPI Cookie dependency  |
+-------------+--------------+
              |
              v
+----------------------------+
| signer.loads(cookie)       |  itsdangerous, validates signature + age
+-------------+--------------+
              |
              v
+----------------------------+
| _store_resolve_session(id) |  one find_one() against oauth_sessions_<ns>
+-------------+--------------+
              |
              v
+----------------------------+
| session_data dict:         |  username, groups, provider, id_token, ...
| {username, groups, ...}    |
+-------------+--------------+
              |
              v
+----------------------------+
| map_cognito_groups_to_     |  walks the scopes-config repo
| scopes(groups) -> scopes  |
+-------------+--------------+
              |
              v
+----------------------------+
| _derive_user_context(...)  |  -> ui_permissions, accessible_servers,
+-------------+--------------+     accessible_tools, is_admin
              |
              v
       request.state.user_context
```

Implementation:
[registry/auth/dependencies.py:521-559](../../registry/auth/dependencies.py#L521-L559).

### 5.2 Failure modes

| Failure | What `enhanced_auth` returns | What the user sees |
|---------|------------------------------|--------------------|
| Cookie missing | 401 | Redirect to `/login`. |
| Cookie present but signature invalid (tampered, wrong SECRET_KEY) | 401 | Redirect to `/login`. |
| Cookie expired (older than `SESSION_MAX_AGE_SECONDS`) | 401 | Redirect to `/login`. |
| Cookie present and valid, but session record missing (TTL'd, manually deleted, store outage) | 401 | Redirect to `/login`. |
| Session found but `auth_method != "oauth2"` | 401 with "Session expired" detail | Forces re-login via OAuth2. |
| Session found and OAuth2 | 200 with full `user_context` on `request.state` | Page renders. |

Critically, the resolver **never raises 500** for transient store failures.
A blip during a Mongo failover degrades to "redirect to login" — the next
request after the store recovers will succeed.

---

## 6. CSRF token binding

Mutating endpoints (POST/PUT/DELETE) require an `X-CSRF-Token` header (or a
`csrf_token` form field). The CSRF token is bound to the **`session_id`**, not
the cookie value. This means CSRF tokens survive cookie format changes and
are tied to the actual server-side session record. See
[registry/auth/csrf.py:23-38](../../registry/auth/csrf.py#L23-L38).

```
generate_csrf_token(session_id)  ->  signed token
validate_csrf_token(token, session_id)  ->  True / False
```

Skip behavior: if the request has no session cookie at all (e.g. it's a
non-browser Bearer-token request), CSRF validation is skipped — those clients
are not subject to CSRF attacks because they don't have a browser session.
See [registry/auth/csrf.py:114-158](../../registry/auth/csrf.py#L114-L158).

---

## 7. Logout

Sequence (driven by `registry/auth/routes.py:189-288`):

1. **Resolve the session** before anything else, to get `provider`, `id_token`,
   and `session_id`.
2. **Delete the server-side record** via `delete_session(session_id)`. This
   closes the cookie-replay window — even if the cookie deletion below fails
   or the browser ignores `Set-Cookie`, the `session_id` no longer resolves on
   the server.
3. **Clear the local cookie** with a `Set-Cookie` containing the same name,
   path, and domain as the original (browsers ignore deletes that don't
   match the original attributes).
4. **Redirect to the IdP's logout endpoint** with `id_token_hint=<token>` so
   the SSO session at the IdP also terminates. If `id_token` is missing
   (unusual; some flows omit it), proceed without the hint and rely on
   browser redirect alone.
5. **Browser ends up at `/login`**, ready to re-authenticate.

Operational visibility: four Prometheus counters track logout outcomes
([registry/auth/routes.py:58-77](../../registry/auth/routes.py#L58-L77)):

- `registry_logout_id_token_hint_present_total`
- `registry_logout_id_token_hint_missing_total`
- `registry_logout_jwt_validation_failed_total`
- `registry_logout_url_length_warning_total`

---

## 8. Multi-replica considerations

| Concern | Behavior |
|---------|----------|
| Cookie signed by replica A, verified by replica B | Works as long as both replicas share `SECRET_KEY`. The previous per-replica random fallback (removed in #1042) caused intermittent `BadSignature` under load-balanced traffic. |
| Session created by replica A, resolved by replica B | Works because the session lives in MongoDB. Read-after-write consistency is handled by `WriteConcern(w="majority")` on insert and `ReadPreference.PRIMARY_PREFERRED` on read. See [auth_server/session_store.py:74-78](../../auth_server/session_store.py#L74-L78). |
| Logout on replica A while a stolen cookie hits replica B | The delete is replicated within the read-after-write window. After that, all replicas see the missing record and return 401. |
| `SECRET_KEY` rotation | Requires restart of every auth-server and registry replica (process-wide AESGCM cipher singleton). All active sessions invalidated. |

---

## 9. Threat model (one-line summary per asset)

| Asset | Threat | Mitigation |
|-------|--------|------------|
| Cookie value (in transit) | Interception | `Secure` flag (HTTPS), `HttpOnly` (no JS access), `SameSite=Lax` (mitigates CSRF for top-level navigations). |
| Cookie value (at rest in browser) | Replay after logout | Server-side session deleted on logout; cookie alone does not prove identity. |
| Session record (in DB) | DB-read leak | `id_token` encrypted at rest with AES-GCM. Username/groups/email are not encrypted because they were already client-visible in the old in-cookie design. |
| `SECRET_KEY` | Compromise | Required at startup, no fallback, must be 32+ bytes from a CSPRNG (see [unified-parameter-reference.md](../unified-parameter-reference.md) `SECRET_KEY` row). |
| Session collection | Mass invalidation needed | Drop the collection — every session invalidated at once. Documented in [docs/operations/incident-response.md](../operations/incident-response.md). |

---

## 10. Common questions

**Q: Why did this used to break Entra logins for admins?**
A: Pre-#1042, the cookie carried the entire session payload including the
`groups` claim. Entra admins routinely have 200+ AD groups; the resulting
cookie blew past the browser's 4 KB limit, was silently truncated/dropped,
and login appeared to succeed but every subsequent request failed.

**Q: Why store `id_token` server-side at all?**
A: SSO logout requires `id_token_hint` per OIDC RP-Initiated Logout
([spec](https://openid.net/specs/openid-connect-rpinitiated-1_0.html)).
Without it, the IdP terminates only the local session; the IdP-side session
remains. Storing it server-side also keeps it out of the cookie (small) and
out of any logged URL or header (safe).

**Q: What happens if the user clears their cookies?**
A: Same as cookie-expired: redirect to `/login`. The server-side record stays
until TTL — that's fine, it's just unreferenced and will be reaped.

**Q: Can two browser tabs hold different sessions?**
A: Not for the same user on the same domain — cookies are domain-scoped, so
both tabs see the same session_id and resolve to the same session. A user
who wants two distinct sessions has to use different browsers or
incognito mode.

**Q: What if the registry restarts mid-request?**
A: The cookie is unaffected, the session record is unaffected. The next
request resumes as normal.
