Add ADR-003 through ADR-010, update TODO.md and CHANGELOG.md
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
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user