> ## Documentation Index
> Fetch the complete documentation index at: https://docs.aspfox.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> JWT RS256, rotating refresh tokens with reuse detection, OAuth, magic links, and impersonation.

## JWT structure

Every AspFox JWT contains these claims:

| Claim             | Type          | Value                                                                              |
| ----------------- | ------------- | ---------------------------------------------------------------------------------- |
| `sub`             | string (GUID) | User ID                                                                            |
| `email`           | string        | User email address                                                                 |
| `name`            | string        | Display name                                                                       |
| `tenant_id`       | string (GUID) | Currently active tenant ID                                                         |
| `role`            | string        | User's role in the active tenant (`Owner`, `Admin`, `Member`, or custom role name) |
| `permissions`     | string\[]     | Array of permission strings for this role                                          |
| `is_admin`        | boolean       | `true` if user has global admin access (Hangfire dashboard, admin panel)           |
| `jti`             | string (GUID) | Unique token ID, used for token family tracking                                    |
| `impersonator_id` | string (GUID) | Present only during admin impersonation; contains the real admin's user ID         |

Access tokens expire after 15 minutes. Refresh tokens expire after 7 days.

## Registration and email verification

New users cannot log in until they verify their email address.

```
POST /api/v1/auth/register
  { "email": "user@example.com", "password": "…", "name": "…" }
    │
    ▼
RegisterCommandHandler
    │  creates User (IsEmailVerified = false)
    │  sends email with verification link
    │  does NOT issue tokens
    │
    ▼
201 Created (no tokens in response)
    │
    ▼
User clicks link in email
  GET /api/v1/auth/verify-email?token=<url-safe-base64-token>
    │
    ▼
VerifyEmailCommandHandler
    │  validates token (not expired, belongs to this user)
    │  sets IsEmailVerified = true
    │  deletes the verification token
    │
    ▼
200 OK with token pair (access + refresh)
User is now logged in.
```

The verification token is URL-safe base64 encoded — no `+`, `=`, or `/` characters appear in the URL. Tokens expire after 24 hours. If expired, the user can request a new one via `POST /api/v1/auth/resend-verification`.

If an unverified user tries to log in, the response is `400 Bad Request` with error code `EMAIL_NOT_VERIFIED`, not `401`. This distinction lets the frontend show an appropriate message with a resend link.

## Refresh token rotation with reuse detection

Every time an access token is refreshed, the old refresh token is revoked and a new one is issued. This is called token rotation.

```
POST /api/v1/auth/refresh  { "refreshToken": "<token>" }
    │
    ▼
RefreshTokenCommandHandler
    │  looks up the token in the database
    │
    ├─ Token found AND not revoked:
    │      revoke the old token
    │      issue new access token + new refresh token
    │      return 200 OK with new token pair
    │
    ├─ Token found AND already revoked:
    │      REUSE DETECTED
    │      revoke ALL tokens in the same family (same FamilyId)
    │      return 401 with code TOKEN_REUSE_DETECTED
    │
    └─ Token not found OR expired:
           return 401 with code TOKEN_EXPIRED or INVALID_TOKEN
```

**Why reuse detection matters:** If an attacker steals a refresh token and uses it before the legitimate user does, the legitimate user's next refresh attempt will fail — their token was already revoked when the attacker refreshed. At that point, the entire token family is revoked, forcing both the attacker and the legitimate user to re-authenticate. This detects the theft and limits the window of compromise.

Token families use the `FamilyId` field on `RefreshToken`. Every refresh creates a new token with the same `FamilyId`. When reuse is detected, a single `UPDATE refresh_tokens SET IsRevoked = true WHERE family_id = @familyId` revokes the entire chain.

## Token storage in the frontend

| Token         | Storage                   | Lifetime                                      |
| ------------- | ------------------------- | --------------------------------------------- |
| Access token  | Zustand store (in-memory) | Lost on page refresh; re-issued automatically |
| Refresh token | `localStorage`            | 7 days                                        |

On page load, the Zustand auth store initializer checks `localStorage` for a stored refresh token. If found and not expired, it calls `POST /api/v1/auth/refresh` silently to get a fresh access token. If the refresh fails (revoked, expired), the user is shown the login page.

The Axios interceptor handles concurrent 401 responses during token refresh. If three API calls fire simultaneously and all return 401, only one refresh request is sent. The other two are queued and retried with the new access token once it arrives.

<Note>
  `localStorage` is accessible to JavaScript on the same origin. This is a known tradeoff for developer-facing boilerplate. If your application has stricter security requirements, you can modify the auth flow to use `httpOnly` cookies for the refresh token. The backend already sets cookies in the response — see `AuthController.SetRefreshTokenCookie()`.
</Note>

## Social login — Google and GitHub OAuth

```
User clicks "Sign in with Google"
    │
    ▼
GET /api/v1/auth/google
    │  redirects to Google's authorization URL
    │
    ▼
User authenticates with Google
    │
    ▼
GET /api/v1/auth/google/callback?code=…&state=…
    │
    ▼
GoogleCallbackHandler
    │  exchanges code for Google token
    │  fetches Google user profile (email, name, picture)
    │
    ├─ User with this email exists:
    │      link Google provider if not already linked
    │      issue token pair for existing user
    │
    └─ No user with this email:
           create new User (IsEmailVerified = true — email verified by Google)
           issue token pair
    │
    ▼
Redirect to frontend with tokens as URL fragment
Frontend stores tokens and redirects to dashboard.
```

GitHub OAuth follows the same pattern via `/api/v1/auth/github` and `/api/v1/auth/github/callback`.

## Magic link authentication

```
POST /api/v1/auth/magic-link/request  { "email": "user@example.com" }
    │
    ▼
Always returns 200 OK (regardless of whether email exists)
If user exists: sends magic link email (10-minute expiry)
    │
    ▼
User clicks link in email
GET /api/v1/auth/magic-link/verify?token=<token>
    │
    ▼
MagicLinkVerifyHandler
    │  validates token (not expired, not used)
    │  marks token as used
    │  if user email not verified: marks as verified
    │  issues token pair
    │
    ▼
Redirect to frontend with tokens
```

Magic links expire after 10 minutes. They are single-use. If a user clicks an expired link, they see a clear message and can request a new one.

## Admin impersonation

Admins can impersonate any user from the admin panel.

```
POST /api/v1/admin/users/{userId}/impersonate
    │  (requires is_admin JWT claim)
    │
    ▼
Issues a new JWT for the target user
    │  with impersonator_id = admin's user ID
    │  with all of the target user's normal claims
    │
    ▼
Frontend detects impersonator_id claim
    │  shows persistent red banner:
    │  "Impersonating {user name} — Stop impersonating"
    │
    ▼
Admin clicks "Stop impersonating"
    │  frontend discards the impersonation token pair
    │  restores the original admin's token pair from memory
    │  red banner disappears
```

Every impersonation event is written to the audit log with the impersonator's ID, the target user's ID, and a timestamp. The impersonation token has the same 15-minute expiry as a normal access token.
