# Authentication Architecture MiauInv uses signed JWT access tokens, database-backed refresh tokens, optional TOTP-based two-factor authentication, and optional WebAuthn passkey authentication. The authentication flow is implemented in the `auth/`, `handlers/`, `storage/`, and `frontend/assets/js/` packages. JWTs are signed with a symmetric secret from the `JWT_SECRET` environment variable. They are not encrypted. Access tokens and normal purpose tokens should therefore contain identity and authorization metadata only, not secrets. The short-lived 2FA setup token intentionally carries the not-yet-enabled TOTP secret because the same secret is already returned to the authenticated browser for QR/manual setup and is not stored server-side until confirmation. ## Components | Component | Location | Responsibility | | --- | --- | --- | | JWT helpers | `auth/jwt.go` | Access-token and purpose-token generation/validation. | | Middleware | `auth/middleware.go` | Extracts access tokens from bearer headers or cookies and injects claims into the request context. | | Password helpers | `auth/password.go` | bcrypt hashing and verification. | | Login/account handlers | `handlers/account.go` | Register, login, 2FA, refresh, logout, account settings, and user metadata. | | Passkey handlers | `handlers/passkeys.go` | WebAuthn/passkey registration, login, removal, and disable flows. | | Persistent session storage | `storage/storage.go`, `storage/passkeys.go` | Refresh tokens, 2FA state, TOTP secret, recovery-code hashes, passkey credentials, and WebAuthn challenge state. | | Frontend auth logic | `frontend/assets/js/auth.js`, `frontend/assets/js/login.js`, `frontend/assets/js/api.js` | Login UI, passkey login, token refresh, account settings, 2FA UI interactions, and passkey UI interactions. | ## Token Types | Token | Storage/Transport | Lifetime | Purpose | | --- | --- | --- | --- | | Access token | `access_token` HTTP-only secure cookie and JSON response body | 15 minutes | Authenticates normal API and page requests. | | Refresh token | `refresh_token` HTTP-only secure cookie and JSON response body | 7 days | Rotates sessions after access-token expiry. Stored in the database only as a hash. | | 2FA challenge token | JSON response from `/api/login` | 5 minutes | Allows `/api/login/2fa` to complete login after password verification. It is purpose-bound to `2fa_login`. | | 2FA setup token | JSON response from `/api/2fa/setup` | 10 minutes | Carries the not-yet-enabled TOTP secret until `/api/2fa/enable` validates the first code. It is purpose-bound to `2fa_setup`. | | Passkey ceremony token | JSON response from `/api/passkeys/*/options` | 5 minutes | Opaque server-side reference to WebAuthn session data stored in `passkey_challenges`. Used for passkey registration and login. | ## Normal Login Flow Without 2FA 1. Client sends `POST /api/login` with `username` and `password`. 2. Server loads the user by username. 3. Server verifies the bcrypt password hash. 4. If 2FA is disabled, the server issues: - a signed access token, - a high-entropy refresh token, - HTTP-only secure cookies for both tokens, - a JSON response containing the same token values and user metadata. 5. The refresh token is hashed before being stored in the `refresh_tokens` table. ## Login Flow With 2FA Enabled 1. Client sends `POST /api/login` with `username` and `password`. 2. Server verifies the password. 3. If `two_factor_enabled` is true, the server does not issue a full session. 4. Instead, the server returns: ```json { "requires_2fa": true, "two_factor_token": "..." } ``` 5. The frontend shows the second login step. 6. Client sends `POST /api/login/2fa` with: ```json { "two_factor_token": "...", "code": "123456" } ``` 7. The server validates the purpose token against the expected purpose `2fa_login`. 8. The server loads the user from the purpose-token claim. 9. The supplied code is checked as a TOTP code first. 10. If the TOTP check fails, the supplied value is normalized and checked as a recovery code. 11. If either check succeeds, the server issues the normal access/refresh token session. 12. If a recovery code was used, it is marked as used and cannot be used again. ## Passkey Registration Flow Passkeys are managed from `/profile/settings`. Registration requires an authenticated session and current-password confirmation. 1. Client sends `POST /api/passkeys/register/options` with a passkey name and the current password. 2. Server verifies the password. 3. Server creates WebAuthn registration options with resident-key and user-verification requirements. 4. Server stores the WebAuthn session data in `passkey_challenges` and returns only an opaque `session_token` plus the public registration options. 5. Browser calls `navigator.credentials.create()` with the returned public-key options. 6. Client sends the browser credential response to `POST /api/passkeys/register/finish`. 7. Server consumes the one-time challenge, verifies the WebAuthn response, stores the credential, revokes existing refresh sessions, and issues a new current session. The server stores credential metadata and public-key credential data. It does not store private keys. ## Passkey Login Flow Passkey login is available from the normal login page. 1. Client sends `POST /api/passkeys/login/options`. 2. Server creates a discoverable passkey login challenge without requiring a username. 3. Server stores the WebAuthn session data in `passkey_challenges` and returns an opaque `session_token` plus assertion options. 4. Browser calls `navigator.credentials.get()` with the returned public-key options. 5. Client sends the browser assertion response to `POST /api/passkeys/login/finish`. 6. Server consumes the one-time challenge and verifies the WebAuthn assertion. 7. The stored credential data is updated after successful login. 8. Server issues a normal access/refresh session. Passkey login is treated as a complete phishing-resistant sign-in method. The application requires WebAuthn user verification for passkey registration and login, so a valid passkey assertion is not followed by a separate TOTP challenge. ## Passkey Management The account settings page supports: - listing registered passkeys, - adding a passkey, - removing a single passkey, - disabling all passkeys. Adding, removing, or disabling passkeys revokes existing refresh sessions and issues a fresh session for the current browser. Removing or disabling passkeys requires current-password confirmation. ## TOTP Setup Flow The account settings page at `/profile/settings` exposes the UI for TOTP setup. 1. Authenticated user calls `POST /api/2fa/setup`. 2. Server creates a TOTP secret using issuer `MiauInv` and the current username as the account name. 3. Server creates a short-lived setup token containing the not-yet-enabled TOTP secret. 4. The secret is not written to `users.two_factor_secret` during setup. 5. Server returns: ```json { "secret": "BASE32SECRET", "setup_token": "...", "otpauth_url": "otpauth://totp/...", "qr_code": "data:image/png;base64,..." } ``` 6. The frontend displays the QR code and the manual setup key. 7. User scans the QR code or enters the secret manually into an authenticator app. 8. User submits the setup token and a current TOTP code to `POST /api/2fa/enable`. 9. Server validates the setup token and the TOTP code. 10. Server stores the TOTP secret, enables 2FA, invalidates any previous recovery codes, and generates a fresh recovery-code set. 11. Server revokes existing refresh sessions and issues a new current session. 12. Recovery codes are returned once to the client for download/copying. ## Recovery Codes Recovery codes are generated when 2FA is enabled and when the user regenerates them. Enabling 2FA deletes any old recovery-code rows before inserting the new set. Properties: - Generated with cryptographically secure randomness. - Formatted as four groups of five hexadecimal characters. - Normalized before hashing by removing spaces and hyphens and lowercasing the value. - Stored only as hashes in `two_factor_recovery_codes`. - Single-use only. - Displayed only immediately after generation/regeneration. - Downloaded client-side from the account settings page as `miauinv-recovery-codes.txt`. Recovery-code login flow: 1. User enters a recovery code in the same field as the TOTP code during the second login step. 2. Server first attempts normal TOTP validation. 3. If TOTP validation fails, server hashes the normalized recovery code. 4. Server updates the matching unused row with `used_at = now`. 5. If exactly one row was updated, the recovery-code login succeeds. ## Recovery-Code Regeneration `POST /api/2fa/recovery-codes/regenerate` requires: ```json { "password": "current-password", "code": "123456" } ``` The server verifies the current password and a current TOTP code. If both are valid, all previous recovery codes are deleted and a new set is inserted. The new plaintext codes are returned once. The account settings UI warns the user when the number of unused recovery codes is low. ## Disabling 2FA `POST /api/2fa/disable` requires: ```json { "password": "current-password", "code": "123456" } ``` If the password and TOTP code are valid, the server: 1. Sets `two_factor_enabled = 0`. 2. Clears `two_factor_secret`. 3. Deletes recovery codes for the user. 4. Revokes refresh tokens for the user. 5. Clears authentication cookies. The frontend redirects the user to `/login` after disabling 2FA because existing sessions are revoked. ## Refresh Token Rotation `POST /api/refresh` accepts the refresh token either from JSON: ```json { "refresh_token": "..." } ``` or from the `refresh_token` cookie. The server: 1. Hashes the supplied token. 2. Looks it up in `refresh_tokens`. 3. Rejects revoked or expired tokens. 4. Marks the used refresh token as revoked. 5. Issues a new access token and refresh token. 6. Stores the new refresh token hash. 7. Updates the auth cookies. ## Account Settings Security The account settings page currently supports: - username changes, - password changes, - passkey registration, - passkey removal, - passkey disable, - TOTP 2FA setup, - TOTP 2FA disable, - recovery-code download after generation, - recovery-code regeneration. Username changes require the current password. Password changes require the current password, reject passwords longer than bcrypt's 72-byte effective limit, revoke existing refresh tokens, and issue a new session. 2FA activation also revokes existing refresh tokens and issues a new session for the current browser. Passkey changes also revoke existing refresh tokens and issue a new current session. Other devices must log in again and complete the configured authentication flow. ## Middleware Behavior Protected routes use `AuthMiddleware`. The middleware checks tokens in this order: 1. `Authorization: Bearer ` header. 2. `access_token` cookie. If no token is found: - `/api/*` routes receive `401 Unauthorized`. - non-API routes redirect to `/login`. If token validation fails: - `/api/*` routes receive `401 Unauthorized`. - non-API routes clear the access cookie and redirect to `/login`. ## Security Limitations and Follow-up Work The current implementation is usable for private/self-hosted deployments, but these improvements should be prioritized before exposing it to untrusted public traffic: - Replace the current in-memory rate limiter with persistent or distributed rate limiting if the app is deployed across multiple instances. - Add audit logging for account security changes. - Add optional session/device management UI. - Consider encrypting TOTP secrets and passkey credential data at rest if the deployment threat model includes database disclosure. - Expand tests for all authentication and account settings handlers.