Skip to content

Commit edf6c40

Browse files
committed
feat: implement stateless JWE authentication and add documentation
1 parent e3c9a02 commit edf6c40

24 files changed

Lines changed: 1999 additions & 83 deletions

.sisyphus/notepads/better-auth-analysis/better-auth-configuration.md

Lines changed: 414 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Decisions
2+
3+
- Keep Better Auth server configuration in `apps/web/src/lib/auth.ts` as the only `auth` export (Next.js route handler depends on it).
4+
- Keep Better Auth React client in `apps/web/src/lib/auth-client.ts` as `authClient` (no `auth` export) to avoid naming collisions and keep concerns clear.
5+
- Implement a small client-side bridge that, once a Better Auth session exists, exchanges the provider access token for backend JWTs via `POST /api/auth/login`, then stores them via `setAccessToken`/`setRefreshToken`.
6+
- Mount the bridge once globally in `apps/web/src/app/providers.tsx` so existing pages don’t need to remember to do token exchange.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Issues
2+
3+
- Backend OAuth login currently only supports `google`, `github`, and `facebook` (`apps/api/src/lib/auth.py`). If additional providers are added to Better Auth, the backend must be extended separately (out of scope).
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Learnings
2+
3+
- Better Auth client can fetch provider OAuth access tokens post-login via `authClient.getAccessToken({ providerId })`.
4+
- Provider selection can be inferred via `authClient.listAccounts()` and reading `providerId`.
5+
- `authClient.getSession()` works well for a non-hook, async bridge that needs `session.user.email`/`name`.
6+
- Backend JWT storage already exists in `apps/web/src/lib/api-client.ts` (localStorage + refresh interceptor), so the missing piece is a one-time OAuth→JWT exchange.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Problems / Follow-ups
2+
3+
- `apps/web/src/lib/api-client.ts` redirects to `/login` on missing/invalid refresh token, but there is currently no `/login` route implemented in `apps/web/src/app` (route group `(auth)` is empty). This isn’t introduced by the auth bridge, but it will matter once auth is wired into UI.

apps/api/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ CORS_ORIGINS=["http://localhost:3000"]
1212
# Auth (better-auth web server)
1313
BETTER_AUTH_URL=http://localhost:3000
1414

15+
# JWT/JWE (stateless authentication)
16+
JWT_SECRET=your-super-secret-jwt-key-change-in-production
17+
JWE_SECRET_KEY=your-super-secret-jwe-encryption-key-change-in-production
18+
1519
# Redis (optional)
1620
REDIS_URL=redis://localhost:6379
1721

apps/api/alembic/env.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
fileConfig(config.config_file_name)
1919

2020
# Set sqlalchemy.url from settings
21-
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC)
21+
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
2222

2323
target_metadata = Base.metadata
2424

apps/api/pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ dependencies = [
2222
"opentelemetry-instrumentation-httpx>=0.49b0",
2323
"opentelemetry-instrumentation-redis>=0.49b0",
2424
"opentelemetry-exporter-otlp>=1.28.0",
25+
"python-jose>=3.3.0",
26+
"cryptography>=44.0.0",
27+
"psycopg2-binary>=2.9.9",
2528
]
2629

2730
[dependency-groups]
@@ -33,6 +36,7 @@ dev = [
3336
"pytest-asyncio>=0.26.0",
3437
"pytest-cov>=6.1.1",
3538
"factory-boy>=3.3.3",
39+
"types-python-jose>=3.3.0.13",
3640
]
3741

3842
[tool.uv]

apps/api/src/auth/__init__.py

Whitespace-only changes.

apps/api/src/auth/router.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from uuid import UUID
2+
3+
from fastapi import APIRouter, Depends, HTTPException, status
4+
from sqlalchemy.ext.asyncio import AsyncSession
5+
6+
from src.lib.auth import (
7+
OAuthLoginRequest,
8+
RefreshTokenRequest,
9+
TokenResponse,
10+
CurrentUserInfo,
11+
decode_token,
12+
verify_oauth_token,
13+
)
14+
from src.lib.dependencies import DBSession
15+
from src.users.model import User
16+
17+
router = APIRouter()
18+
19+
20+
@router.post("/login", response_model=TokenResponse)
21+
async def login(
22+
request: OAuthLoginRequest,
23+
db: DBSession,
24+
) -> TokenResponse:
25+
"""OAuth login endpoint.
26+
27+
Verify OAuth token, create/update user, and issue JWE tokens.
28+
"""
29+
user_info = await verify_oauth_token(request.provider, request.access_token)
30+
31+
from sqlalchemy import select
32+
33+
result = await db.execute(select(User).where(User.email == user_info.email))
34+
user = result.scalar_one_or_none()
35+
36+
if not user:
37+
user = User(
38+
email=user_info.email,
39+
name=user_info.name,
40+
image=user_info.image,
41+
email_verified=user_info.email_verified,
42+
)
43+
db.add(user)
44+
await db.commit()
45+
await db.refresh(user)
46+
47+
from src.lib.auth import create_access_token, create_refresh_token
48+
49+
access_token = create_access_token(str(user.id))
50+
refresh_token = create_refresh_token(str(user.id))
51+
52+
return TokenResponse(
53+
access_token=access_token,
54+
refresh_token=refresh_token,
55+
)
56+
57+
58+
@router.post("/refresh", response_model=TokenResponse)
59+
async def refresh_token(
60+
request: RefreshTokenRequest,
61+
db: DBSession,
62+
) -> TokenResponse:
63+
"""Refresh access token using refresh token."""
64+
payload = decode_token(request.refresh_token)
65+
66+
if payload.token_type != "refresh":
67+
raise HTTPException(
68+
status_code=status.HTTP_401_UNAUTHORIZED,
69+
detail="Invalid token type",
70+
)
71+
72+
from sqlalchemy import select
73+
74+
result = await db.execute(select(User).where(User.id == UUID(payload.user_id)))
75+
user = result.scalar_one_or_none()
76+
77+
if not user:
78+
raise HTTPException(
79+
status_code=status.HTTP_401_UNAUTHORIZED,
80+
detail="User not found",
81+
)
82+
83+
from src.lib.auth import create_access_token
84+
85+
access_token = create_access_token(str(user.id))
86+
87+
return TokenResponse(
88+
access_token=access_token,
89+
refresh_token=request.refresh_token,
90+
)
91+
92+
93+
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
94+
async def logout() -> None:
95+
"""Logout endpoint.
96+
97+
Client should remove tokens from localStorage.
98+
"""
99+
return None

0 commit comments

Comments
 (0)