ca648ccf8b
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/dd261f53-4891-437e-bd71-5561ece62d7d
151 lines
6.3 KiB
Markdown
151 lines
6.3 KiB
Markdown
# ADR 007: Use JWT for API Authentication
|
|
|
|
**Status:** Accepted
|
|
**Date:** 2026-02-08
|
|
**Deciders:** Security Team, Development Team
|
|
|
|
## Context
|
|
|
|
The multi-tenant SaaS API needs a stateless authentication mechanism that:
|
|
|
|
1. Works with both browser-based frontends and programmatic API clients
|
|
2. Supports short-lived access tokens to limit exposure on compromise
|
|
3. Allows session refresh without re-entering credentials
|
|
4. Integrates with Google OAuth2 (users who sign in via Google)
|
|
5. Requires no server-side session store
|
|
|
|
## Decision
|
|
|
|
We will use **JWT (JSON Web Tokens)** with **HMAC-SHA256 (HS256)** signing for both access tokens and refresh tokens, implemented via the `python-jose` library.
|
|
|
|
- **Access tokens**: 30-minute TTL, sent in `Authorization: Bearer <token>` header
|
|
- **Refresh tokens**: 7-day TTL, exchanged for a new access token
|
|
- **Token subject**: User ID (stored as `str` in `sub` claim, decoded to `int` in deps)
|
|
|
|
## Alternatives Considered
|
|
|
|
### 1. Session Cookies (server-side sessions)
|
|
- **Pros**: Easy revocation, browser-native, CSRF protectable with SameSite
|
|
- **Cons**: Requires server-side session store (Redis), doesn't work well for API-first architecture, harder to use from mobile/CLI clients
|
|
|
|
### 2. OAuth2 Opaque Tokens
|
|
- **Pros**: Easy revocation, no token content leakage
|
|
- **Cons**: Every request requires a database lookup to validate the token (not stateless)
|
|
|
|
### 3. JWT with RS256 (RSA asymmetric signing)
|
|
- **Pros**: Public key verification allows third-party validation without sharing secret
|
|
- **Cons**: Additional key management complexity, slower to sign/verify, not required at current scale
|
|
|
|
### 4. API Keys (long-lived static tokens)
|
|
- **Pros**: Simple for machine-to-machine, easy to understand
|
|
- **Cons**: Long-lived tokens increase risk on compromise, no user-session semantics
|
|
|
|
### 5. Paseto (Platform-Agnostic Security Tokens)
|
|
- **Pros**: Better defaults than JWT (no algorithm confusion attacks), cleaner spec
|
|
- **Cons**: Less widespread adoption, fewer library options in Python, migration cost from JWT
|
|
|
|
## Rationale
|
|
|
|
JWT with HS256 was chosen because:
|
|
|
|
1. **Stateless**: No database lookup required to validate a token — the signature is self-authenticating
|
|
2. **Short TTL**: 30-minute access tokens limit the window of opportunity if a token is stolen
|
|
3. **Refresh Token Pattern**: 7-day refresh tokens allow long sessions without exposing long-lived access tokens
|
|
4. **Standard**: JWT is the de-facto standard for REST API authentication
|
|
5. **OAuth2 Compatibility**: Google OAuth2 tokens can be exchanged for our own JWTs, giving unified auth handling
|
|
6. **`python-jose`**: Well-maintained library with HS256/RS256 support and JWKS endpoint capability
|
|
|
|
## Implementation
|
|
|
|
```python
|
|
# backend/app/core/security.py
|
|
from datetime import datetime, timedelta, timezone
|
|
from jose import jwt, JWTError
|
|
|
|
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
|
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
|
|
|
def create_access_token(subject: str) -> str:
|
|
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
payload = {"sub": subject, "exp": expire, "type": "access"}
|
|
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
|
|
|
def create_refresh_token(subject: str) -> str:
|
|
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
|
payload = {"sub": subject, "exp": expire, "type": "refresh"}
|
|
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
|
|
|
def decode_token(token: str) -> dict:
|
|
return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
|
```
|
|
|
|
```python
|
|
# backend/app/core/deps.py
|
|
from fastapi import Depends, HTTPException, status
|
|
from fastapi.security import OAuth2PasswordBearer
|
|
|
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
|
|
|
async def get_current_user(
|
|
token: str = Depends(oauth2_scheme),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> User:
|
|
try:
|
|
payload = decode_token(token)
|
|
user_id = int(payload["sub"]) # sub is str (jose requirement), decode to int
|
|
except (JWTError, KeyError, ValueError):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
user = await db.get(User, user_id)
|
|
if user is None:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
return user
|
|
```
|
|
|
|
### Token Subject Encoding
|
|
|
|
The `sub` claim uses `str(user.id)` when creating tokens and `int(payload["sub"])` when decoding. This is required because `python-jose` mandates string subjects per the JWT spec.
|
|
|
|
## Consequences
|
|
|
|
### Positive
|
|
- No database query per request for authentication (stateless validation)
|
|
- Short access token TTL limits blast radius of token theft
|
|
- Works identically for browser, mobile, and CLI clients
|
|
- Google OAuth flow exchanges Google's token for our own JWT (unified handling)
|
|
|
|
### Negative
|
|
- No instant token revocation (must wait for TTL to expire)
|
|
- Refresh token theft allows extended session hijacking
|
|
- `SECRET_KEY` compromise invalidates all tokens and requires rotation
|
|
|
|
### Token Revocation Strategy
|
|
|
|
For logout and forced revocation, a **token blocklist** stored in Redis can be used:
|
|
```python
|
|
# Add jti (JWT ID) claim to tokens
|
|
# On logout: store jti in Redis with TTL = token TTL
|
|
# On each request: check if jti is blocklisted
|
|
```
|
|
|
|
This is planned but not yet implemented; current logout deletes the token client-side only.
|
|
|
|
## Security Considerations
|
|
|
|
1. **HTTPS Only**: JWTs must only be transmitted over TLS in production
|
|
2. **No Sensitive Data in Payload**: JWT payload is base64-encoded, not encrypted — never put passwords or PII in claims
|
|
3. **Algorithm Pinning**: Always specify `algorithms=["HS256"]` in `jwt.decode()` to prevent algorithm confusion attacks
|
|
4. **Secret Key Length**: `SECRET_KEY` must be ≥ 32 characters (validated at startup)
|
|
5. **Token Storage**: Frontend stores tokens in `localStorage` (XSS risk); consider `httpOnly` cookies for hardened deployments
|
|
|
|
## Related Decisions
|
|
|
|
- See ADR-006 for SECRET_KEY management
|
|
- See ADR-003 for FastAPI dependency injection (`Depends(get_current_user)`)
|
|
|
|
## References
|
|
|
|
- [RFC 7519: JSON Web Token](https://www.rfc-editor.org/rfc/rfc7519)
|
|
- [python-jose Documentation](https://python-jose.readthedocs.io/)
|
|
- [JWT Best Practices (RFC 8725)](https://www.rfc-editor.org/rfc/rfc8725)
|
|
- [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
|