fix: implement vault key decryption on login + fix token refresh via cookie
Three bugs fixed: 1. Phase 30 (auth.ts): After SRP login the encrypted_key_set was returned from the server but the vault key and RSA private key were never unwrapped with the AUK. keyStore.getVaultKey() was always null, causing Tier 1 config-backup diffs to crash with a TypeError. Fix: unwrap vault key and private key using crypto.subtle.unwrapKey after successful SRP verification. Non-fatal: warns to console if decryption fails so login always succeeds. 2. Token refresh (auth.py): The /refresh endpoint required refresh_token in the request body, but the frontend never stored or sent it. After the 15- minute access token TTL, all authenticated API calls would fail silently because the interceptor sent an empty body and received 422 (not 401), so the retry loop never fired. Fix: login/srpVerify now set an httpOnly refresh_token cookie scoped to /api/auth/refresh. The refresh endpoint now accepts the token from either cookie (preferred) or body (legacy). Logout clears both cookies. RefreshRequest.refresh_token is now Optional to allow empty-body calls. 3. Silent token rotation: the /refresh endpoint now also rotates the refresh token cookie on each use (issues a fresh token), reducing the window for stolen refresh token replay.
This commit is contained in:
@@ -75,6 +75,10 @@ router = APIRouter(prefix="/auth", tags=["auth"])
|
|||||||
ACCESS_TOKEN_COOKIE = "access_token"
|
ACCESS_TOKEN_COOKIE = "access_token"
|
||||||
ACCESS_TOKEN_MAX_AGE = 15 * 60 # 15 minutes in seconds
|
ACCESS_TOKEN_MAX_AGE = 15 * 60 # 15 minutes in seconds
|
||||||
|
|
||||||
|
# Refresh token cookie settings (httpOnly, longer-lived)
|
||||||
|
REFRESH_TOKEN_COOKIE = "refresh_token"
|
||||||
|
REFRESH_TOKEN_MAX_AGE = 7 * 24 * 60 * 60 # 7 days in seconds
|
||||||
|
|
||||||
# Cookie Secure flag requires HTTPS. Safari strictly enforces this —
|
# Cookie Secure flag requires HTTPS. Safari strictly enforces this —
|
||||||
# it silently drops Secure cookies over plain HTTP, unlike Chrome
|
# it silently drops Secure cookies over plain HTTP, unlike Chrome
|
||||||
# which exempts localhost. Auto-detect from CORS origins: if all
|
# which exempts localhost. Auto-detect from CORS origins: if all
|
||||||
@@ -239,7 +243,7 @@ async def srp_verify_endpoint(
|
|||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Set cookie (same as existing login)
|
# Set access token cookie
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=ACCESS_TOKEN_COOKIE,
|
key=ACCESS_TOKEN_COOKIE,
|
||||||
value=access_token,
|
value=access_token,
|
||||||
@@ -248,6 +252,16 @@ async def srp_verify_endpoint(
|
|||||||
secure=_COOKIE_SECURE,
|
secure=_COOKIE_SECURE,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
)
|
)
|
||||||
|
# Set refresh token cookie (httpOnly, scoped to refresh endpoint)
|
||||||
|
response.set_cookie(
|
||||||
|
key=REFRESH_TOKEN_COOKIE,
|
||||||
|
value=refresh_token,
|
||||||
|
max_age=REFRESH_TOKEN_MAX_AGE,
|
||||||
|
httponly=True,
|
||||||
|
secure=_COOKIE_SECURE,
|
||||||
|
samesite="lax",
|
||||||
|
path="/api/auth/refresh",
|
||||||
|
)
|
||||||
|
|
||||||
# Fetch encrypted key set
|
# Fetch encrypted key set
|
||||||
key_set = await get_user_key_set(db, user.id)
|
key_set = await get_user_key_set(db, user.id)
|
||||||
@@ -360,6 +374,18 @@ async def login(
|
|||||||
secure=_COOKIE_SECURE,
|
secure=_COOKIE_SECURE,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
)
|
)
|
||||||
|
# Also set refresh token as httpOnly cookie so auto-refresh works
|
||||||
|
# without the frontend needing to persist the token in JS memory.
|
||||||
|
if not user.must_upgrade_auth:
|
||||||
|
response.set_cookie(
|
||||||
|
key=REFRESH_TOKEN_COOKIE,
|
||||||
|
value=refresh,
|
||||||
|
max_age=REFRESH_TOKEN_MAX_AGE,
|
||||||
|
httponly=True,
|
||||||
|
secure=_COOKIE_SECURE,
|
||||||
|
samesite="lax",
|
||||||
|
path="/api/auth/refresh", # scope cookie to refresh endpoint only
|
||||||
|
)
|
||||||
|
|
||||||
# Update last_login
|
# Update last_login
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -400,17 +426,29 @@ async def login(
|
|||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def refresh_token(
|
async def refresh_token(
|
||||||
request: StarletteRequest,
|
request: StarletteRequest,
|
||||||
body: RefreshRequest,
|
body: Optional[RefreshRequest] = None,
|
||||||
response: Response,
|
response: Response = None,
|
||||||
db: AsyncSession = Depends(get_admin_db),
|
db: AsyncSession = Depends(get_admin_db),
|
||||||
redis: aioredis.Redis = Depends(get_redis),
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
refresh_token_cookie: Optional[str] = Cookie(default=None, alias="refresh_token"),
|
||||||
) -> TokenResponse:
|
) -> TokenResponse:
|
||||||
"""
|
"""
|
||||||
Exchange a valid refresh token for a new access token.
|
Exchange a valid refresh token for a new access token.
|
||||||
|
|
||||||
|
Accepts the refresh token either in the JSON body (legacy) or as an
|
||||||
|
httpOnly cookie named 'refresh_token' (preferred — set automatically at login).
|
||||||
Rate limited to 10 requests per minute per IP.
|
Rate limited to 10 requests per minute per IP.
|
||||||
"""
|
"""
|
||||||
|
# Resolve token: body takes precedence over cookie
|
||||||
|
raw_token = (body.refresh_token if body and body.refresh_token else None) or refresh_token_cookie
|
||||||
|
if not raw_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="No refresh token provided",
|
||||||
|
)
|
||||||
|
|
||||||
# Validate refresh token
|
# Validate refresh token
|
||||||
payload = verify_token(body.refresh_token, expected_type="refresh")
|
payload = verify_token(raw_token, expected_type="refresh")
|
||||||
|
|
||||||
user_id_str = payload.get("sub")
|
user_id_str = payload.get("sub")
|
||||||
if not user_id_str:
|
if not user_id_str:
|
||||||
@@ -453,7 +491,7 @@ async def refresh_token(
|
|||||||
)
|
)
|
||||||
new_refresh_token = create_refresh_token(user_id=user.id)
|
new_refresh_token = create_refresh_token(user_id=user.id)
|
||||||
|
|
||||||
# Update cookie
|
# Rotate access token cookie
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=ACCESS_TOKEN_COOKIE,
|
key=ACCESS_TOKEN_COOKIE,
|
||||||
value=new_access_token,
|
value=new_access_token,
|
||||||
@@ -462,6 +500,16 @@ async def refresh_token(
|
|||||||
secure=_COOKIE_SECURE,
|
secure=_COOKIE_SECURE,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
)
|
)
|
||||||
|
# Rotate refresh token cookie (silent token rotation)
|
||||||
|
response.set_cookie(
|
||||||
|
key=REFRESH_TOKEN_COOKIE,
|
||||||
|
value=new_refresh_token,
|
||||||
|
max_age=REFRESH_TOKEN_MAX_AGE,
|
||||||
|
httponly=True,
|
||||||
|
secure=_COOKIE_SECURE,
|
||||||
|
samesite="lax",
|
||||||
|
path="/api/auth/refresh",
|
||||||
|
)
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=new_access_token,
|
access_token=new_access_token,
|
||||||
@@ -501,6 +549,13 @@ async def logout(
|
|||||||
secure=_COOKIE_SECURE,
|
secure=_COOKIE_SECURE,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
)
|
)
|
||||||
|
response.delete_cookie(
|
||||||
|
key=REFRESH_TOKEN_COOKIE,
|
||||||
|
httponly=True,
|
||||||
|
secure=_COOKIE_SECURE,
|
||||||
|
samesite="lax",
|
||||||
|
path="/api/auth/refresh",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/change-password", response_model=MessageResponse, summary="Change password for authenticated user")
|
@router.post("/change-password", response_model=MessageResponse, summary="Change password for authenticated user")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class TokenResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class RefreshRequest(BaseModel):
|
class RefreshRequest(BaseModel):
|
||||||
refresh_token: str
|
refresh_token: Optional[str] = None # Optional: also accepted via httpOnly cookie
|
||||||
|
|
||||||
|
|
||||||
class UserMeResponse(BaseModel):
|
class UserMeResponse(BaseModel):
|
||||||
|
|||||||
@@ -145,7 +145,43 @@ export const useAuth = create<AuthState>((set, get) => ({
|
|||||||
|
|
||||||
// 8. Store AUK and unlock key set
|
// 8. Store AUK and unlock key set
|
||||||
keyStore.setAUK(auk)
|
keyStore.setAUK(auk)
|
||||||
// TODO (Phase 30): Decrypt encrypted_key_set with AUK to get vault key
|
|
||||||
|
// Decrypt encrypted_key_set with AUK to get vault key + RSA private key.
|
||||||
|
// Non-fatal: if decryption fails (e.g. corrupted key set, wrong AUK) we log
|
||||||
|
// a warning and continue. Server-side Transit encryption still works; only
|
||||||
|
// Tier 1 (client-side) encrypted data will be inaccessible until re-auth.
|
||||||
|
if (result.encrypted_key_set) {
|
||||||
|
const ks = result.encrypted_key_set
|
||||||
|
try {
|
||||||
|
const b64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0))
|
||||||
|
|
||||||
|
// Unwrap vault key (AES-256-GCM) using AUK
|
||||||
|
const vaultKey = await crypto.subtle.unwrapKey(
|
||||||
|
'raw',
|
||||||
|
b64(ks.encrypted_vault_key),
|
||||||
|
auk,
|
||||||
|
{ name: 'AES-GCM', iv: b64(ks.vault_key_nonce) },
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false, // non-extractable
|
||||||
|
['encrypt', 'decrypt'],
|
||||||
|
)
|
||||||
|
keyStore.setVaultKey(vaultKey)
|
||||||
|
|
||||||
|
// Unwrap RSA-OAEP private key using AUK
|
||||||
|
const privateKey = await crypto.subtle.unwrapKey(
|
||||||
|
'pkcs8',
|
||||||
|
b64(ks.encrypted_private_key),
|
||||||
|
auk,
|
||||||
|
{ name: 'AES-GCM', iv: b64(ks.private_key_nonce) },
|
||||||
|
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
||||||
|
false, // non-extractable
|
||||||
|
['decrypt'],
|
||||||
|
)
|
||||||
|
keyStore.setPrivateKey(privateKey)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[auth] key set decryption failed (Tier 1 data will be inaccessible):', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 9. Store Secret Key in IndexedDB for future logins on this device
|
// 9. Store Secret Key in IndexedDB for future logins on this device
|
||||||
await keyStore.storeSecretKey(email, secretKeyBytes)
|
await keyStore.storeSecretKey(email, secretKeyBytes)
|
||||||
|
|||||||
Reference in New Issue
Block a user