Spaces:
Sleeping
Sleeping
samar m commited on
Commit Β·
ec8498d
1
Parent(s): 7910b95
docs: add Phase 1 auth system design spec
Browse files
docs/superpowers/specs/2026-03-28-phase-1-auth-design.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Phase 1 β Auth System Design
|
| 2 |
+
|
| 3 |
+
**Date:** 2026-03-28
|
| 4 |
+
**Status:** Approved
|
| 5 |
+
|
| 6 |
+
## Goal
|
| 7 |
+
|
| 8 |
+
Implement full JWT-based authentication: backend routes, DB queries, password hashing, token management, and frontend login/signup UI with Zustand state.
|
| 9 |
+
|
| 10 |
+
## Backend
|
| 11 |
+
|
| 12 |
+
### Files
|
| 13 |
+
- `backend/auth/password.py` β bcrypt hash (cost=12) and verify
|
| 14 |
+
- `backend/auth/jwt.py` β sign and verify HS256 access token (15min); generate opaque refresh token string
|
| 15 |
+
- `backend/db/connection.py` β asyncpg connection pool, initialised via `init_db_pool()` on startup
|
| 16 |
+
- `backend/db/queries.py` β all SQL: user insert, user lookup by email, refresh token insert/lookup/delete
|
| 17 |
+
- `backend/routers/auth.py` β four routes with request/response Pydantic models
|
| 18 |
+
- `backend/main.py` β wire routers, call `init_db_pool()` in lifespan
|
| 19 |
+
|
| 20 |
+
### Auth Flow
|
| 21 |
+
|
| 22 |
+
```
|
| 23 |
+
SIGNUP POST /api/auth/signup
|
| 24 |
+
Body: { full_name, email, password, role, class_code? }
|
| 25 |
+
β if role=student: lookup batch by class_code, get batch_id (400 if not found)
|
| 26 |
+
β bcrypt hash password (cost=12)
|
| 27 |
+
β INSERT user
|
| 28 |
+
β sign access token { user_id, role, batch_id, email, exp }
|
| 29 |
+
β generate refresh token string β hash β INSERT refresh_tokens
|
| 30 |
+
β Set-Cookie: refresh_token=<raw> (httpOnly, SameSite=Lax)
|
| 31 |
+
β return { access_token, user: { id, role, full_name, batch_id } }
|
| 32 |
+
|
| 33 |
+
LOGIN POST /api/auth/login
|
| 34 |
+
Body: { email, password }
|
| 35 |
+
β lookup user by email (401 if not found)
|
| 36 |
+
β bcrypt verify (401 if wrong)
|
| 37 |
+
β same token flow as signup
|
| 38 |
+
|
| 39 |
+
REFRESH POST /api/auth/refresh
|
| 40 |
+
β read refresh_token cookie (401 if missing)
|
| 41 |
+
β hash cookie value β lookup in refresh_tokens (401 if not found or expired)
|
| 42 |
+
β sign new access token
|
| 43 |
+
β return { access_token }
|
| 44 |
+
|
| 45 |
+
LOGOUT POST /api/auth/logout
|
| 46 |
+
β read refresh_token cookie
|
| 47 |
+
β DELETE from refresh_tokens where token_hash matches
|
| 48 |
+
β clear cookie
|
| 49 |
+
β return { ok: true }
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
### Route Protection (added to main.py, used in Phase 2+)
|
| 53 |
+
|
| 54 |
+
```python
|
| 55 |
+
async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
|
| 56 |
+
# verify JWT, return payload
|
| 57 |
+
# raises 401 on failure
|
| 58 |
+
|
| 59 |
+
async def require_instructor(user = Depends(get_current_user)) -> dict: ...
|
| 60 |
+
async def require_student(user = Depends(get_current_user)) -> dict: ...
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
## Frontend
|
| 64 |
+
|
| 65 |
+
### Files
|
| 66 |
+
- `frontend/src/api/client.ts` β base `apiFetch`: injects Bearer token, retries once on 401 after refresh
|
| 67 |
+
- `frontend/src/api/auth.ts` β `signup()`, `login()`, `logout()`, `refreshToken()`
|
| 68 |
+
- `frontend/src/store/authStore.ts` β Zustand store, persisted to localStorage
|
| 69 |
+
- `frontend/src/App.tsx` β React Router v6, role-based redirect, ProtectedRoute
|
| 70 |
+
- `frontend/src/pages/Login.tsx` β dark card form, error state, loading button
|
| 71 |
+
- `frontend/src/pages/Signup.tsx` β same style + role toggle + conditional class code field
|
| 72 |
+
|
| 73 |
+
### Zustand Store Shape
|
| 74 |
+
|
| 75 |
+
```typescript
|
| 76 |
+
interface AuthStore {
|
| 77 |
+
accessToken: string | null
|
| 78 |
+
user: { id: string; role: string; full_name: string; batch_id: string | null } | null
|
| 79 |
+
setAuth: (token: string, user: User) => void
|
| 80 |
+
clearAuth: () => void
|
| 81 |
+
}
|
| 82 |
+
// persisted to localStorage via zustand/middleware persist
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
### apiFetch Behaviour
|
| 86 |
+
|
| 87 |
+
1. Get `accessToken` from `authStore`
|
| 88 |
+
2. Fetch with `Authorization: Bearer <token>` + `credentials: 'include'`
|
| 89 |
+
3. If response is 401 β call `refreshToken()` β retry original request once
|
| 90 |
+
4. If retry also 401 β `clearAuth()` + redirect to `/login`
|
| 91 |
+
|
| 92 |
+
### Routing (App.tsx)
|
| 93 |
+
|
| 94 |
+
```
|
| 95 |
+
/login β Login (redirect to dashboard if already authed)
|
| 96 |
+
/signup β Signup (redirect to dashboard if already authed)
|
| 97 |
+
/student/* β ProtectedRoute (role=student)
|
| 98 |
+
/instructor/* β ProtectedRoute (role=instructor)
|
| 99 |
+
/ β redirect based on role
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
### UI Spec (Login + Signup)
|
| 103 |
+
|
| 104 |
+
- Dark card: `bg-gray-900 border border-gray-800 rounded-xl p-8 w-full max-w-md`
|
| 105 |
+
- Page bg: `min-h-screen bg-gray-950 flex items-center justify-center`
|
| 106 |
+
- Input: `bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white w-full`
|
| 107 |
+
- Button: `bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg px-4 py-2 w-full`
|
| 108 |
+
- Loading: button disabled + "..." text
|
| 109 |
+
- Error: red text below form `text-red-400 text-sm`
|
| 110 |
+
- Signup role toggle: two buttons (Student / Instructor), active = indigo fill
|
| 111 |
+
- Class code field: visible only when role = student
|
| 112 |
+
|
| 113 |
+
## Key Decisions
|
| 114 |
+
|
| 115 |
+
- Refresh token stored as raw string in cookie; only the bcrypt hash goes to DB β cookie theft alone is not enough
|
| 116 |
+
- `apiFetch` handles token refresh transparently β pages never deal with 401 manually
|
| 117 |
+
- Zustand `persist` middleware saves auth to localStorage so page refresh doesn't log users out
|
| 118 |
+
- `class_code` lookup happens at signup time; `batch_id` is embedded in the JWT so subsequent requests don't need a DB lookup for basic auth
|