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.
302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
import { create } from 'zustand'
|
|
import { authApi, type UserMe } from './api'
|
|
import { keyStore } from './crypto/keyStore'
|
|
import { deriveKeysInWorker } from './crypto/keys'
|
|
import { SRPClient } from './crypto/srp'
|
|
import { parseSecretKey } from './crypto/secretKey'
|
|
import { assertWebCryptoAvailable } from './crypto/registration'
|
|
import { getAuthErrorMessage } from './errors'
|
|
|
|
interface AuthState {
|
|
user: UserMe | null
|
|
isAuthenticated: boolean
|
|
isLoading: boolean
|
|
error: string | null
|
|
needsSecretKey: boolean // True when SRP user on new device needs Secret Key
|
|
isDerivingKeys: boolean // True during PBKDF2 computation
|
|
isUpgrading: boolean // True when legacy bcrypt user is upgrading to SRP
|
|
pendingUpgradeEmail: string | null // Email of user being upgraded
|
|
pendingUpgradePassword: string | null // Password of user being upgraded (for SRP derivation)
|
|
|
|
login: (email: string, password: string) => Promise<void>
|
|
srpLogin: (email: string, password: string, secretKeyInput?: string) => Promise<void>
|
|
logout: () => Promise<void>
|
|
checkAuth: () => Promise<void>
|
|
clearError: () => void
|
|
clearNeedsSecretKey: () => void
|
|
completeUpgrade: () => Promise<void>
|
|
cancelUpgrade: () => void
|
|
}
|
|
|
|
export const useAuth = create<AuthState>((set, get) => ({
|
|
user: null,
|
|
isAuthenticated: false,
|
|
isLoading: true,
|
|
error: null,
|
|
needsSecretKey: false,
|
|
isDerivingKeys: false,
|
|
isUpgrading: false,
|
|
pendingUpgradeEmail: null,
|
|
pendingUpgradePassword: null,
|
|
|
|
login: async (email: string, password: string) => {
|
|
set({ isLoading: true, error: null })
|
|
try {
|
|
const result = await authApi.login({ email, password })
|
|
|
|
// Check if this is a legacy bcrypt user needing SRP upgrade
|
|
if (result.auth_upgrade_required) {
|
|
// Only show upgrade dialog if Web Crypto is available (requires HTTPS or localhost).
|
|
// If not, skip upgrade and proceed with bcrypt session — upgrade happens next HTTPS visit.
|
|
const hasCrypto = typeof crypto !== 'undefined' && !!crypto.subtle
|
|
if (hasCrypto) {
|
|
set({
|
|
isLoading: false,
|
|
isUpgrading: true,
|
|
pendingUpgradeEmail: email,
|
|
pendingUpgradePassword: password,
|
|
})
|
|
return
|
|
}
|
|
// Fall through to complete login without SRP upgrade
|
|
}
|
|
|
|
const user = await authApi.me()
|
|
set({ user, isAuthenticated: true, isLoading: false, error: null })
|
|
} catch (err: unknown) {
|
|
// Check if 409 srp_required -- redirect to SRP flow
|
|
const axiosErr = err as { response?: { status?: number; data?: { detail?: string } } }
|
|
if (
|
|
axiosErr?.response?.status === 409 &&
|
|
axiosErr?.response?.data?.detail === 'srp_required'
|
|
) {
|
|
// User has SRP auth -- try SRP flow
|
|
return get().srpLogin(email, password)
|
|
}
|
|
const message = getAuthErrorMessage(err)
|
|
set({
|
|
isLoading: false,
|
|
isAuthenticated: false,
|
|
user: null,
|
|
error: message,
|
|
})
|
|
throw new Error(message)
|
|
}
|
|
},
|
|
|
|
srpLogin: async (email: string, password: string, secretKeyInput?: string) => {
|
|
set({ isLoading: true, isDerivingKeys: true, error: null })
|
|
|
|
try {
|
|
// 0. Verify Web Crypto API is available (requires HTTPS or localhost)
|
|
assertWebCryptoAvailable()
|
|
|
|
// 1. Get Secret Key (from IndexedDB or user input)
|
|
let secretKeyBytes: Uint8Array | null = await keyStore.getSecretKey(email)
|
|
if (!secretKeyBytes && secretKeyInput) {
|
|
secretKeyBytes = parseSecretKey(secretKeyInput)
|
|
if (!secretKeyBytes) {
|
|
set({ error: 'Invalid Secret Key format', isLoading: false, isDerivingKeys: false })
|
|
return
|
|
}
|
|
}
|
|
if (!secretKeyBytes) {
|
|
set({ needsSecretKey: true, isLoading: false, isDerivingKeys: false })
|
|
return
|
|
}
|
|
|
|
// 2. SRP Step 1: init (returns salt, B, session_id, AND key derivation salts)
|
|
const { salt, server_public, session_id, pbkdf2_salt, hkdf_salt } =
|
|
await authApi.srpInit(email)
|
|
|
|
// 3. Decode base64 salts returned by /srp/init from user_key_sets
|
|
const pbkdf2SaltBytes = Uint8Array.from(atob(pbkdf2_salt), (c) => c.charCodeAt(0))
|
|
const hkdfSaltBytes = Uint8Array.from(atob(hkdf_salt), (c) => c.charCodeAt(0))
|
|
|
|
// 4. Derive keys in Web Worker (PBKDF2 650K iterations)
|
|
const { auk, srpX } = await deriveKeysInWorker({
|
|
masterPassword: password,
|
|
secretKeyBytes,
|
|
email,
|
|
accountId: email, // Use email as accountId for key derivation
|
|
pbkdf2Salt: pbkdf2SaltBytes,
|
|
hkdfSalt: hkdfSaltBytes,
|
|
})
|
|
|
|
set({ isDerivingKeys: false })
|
|
|
|
// 5. SRP handshake
|
|
const srpClient = new SRPClient(email)
|
|
const { clientProof } = await srpClient.computeSession(srpX, salt, server_public)
|
|
|
|
// 6. SRP Step 2: verify
|
|
const result = await authApi.srpVerify({
|
|
email,
|
|
session_id,
|
|
client_public: srpClient.getPublicEphemeral(),
|
|
client_proof: clientProof,
|
|
})
|
|
|
|
// 7. Verify server proof M2
|
|
const serverValid = await srpClient.verifyServerProof(result.server_proof)
|
|
if (!serverValid) {
|
|
throw new Error('Server authentication failed')
|
|
}
|
|
|
|
// 8. Store AUK and unlock key set
|
|
keyStore.setAUK(auk)
|
|
|
|
// 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
|
|
await keyStore.storeSecretKey(email, secretKeyBytes)
|
|
|
|
// 10. Fetch user profile
|
|
const user = await authApi.me()
|
|
set({ user, isAuthenticated: true, isLoading: false, needsSecretKey: false })
|
|
} catch (err) {
|
|
keyStore.clearAll()
|
|
const axErr = err as { response?: { status?: number; data?: { detail?: string } } }
|
|
const detail = axErr?.response?.data?.detail ?? ''
|
|
let message: string
|
|
if (axErr?.response?.status === 401) {
|
|
// SRP proof failed — wrong password, wrong Secret Key, or stale credentials.
|
|
// If the user didn't manually provide a key, the stored IndexedDB key is wrong
|
|
// (e.g. server was rebuilt, user re-enrolled). Show the Secret Key field so they
|
|
// can enter their current key from their Emergency Kit.
|
|
if (!secretKeyInput) {
|
|
set({ needsSecretKey: true, isLoading: false, isDerivingKeys: false, error: 'This device has an outdated Secret Key. Please enter your current Secret Key from your Emergency Kit.' })
|
|
return
|
|
}
|
|
message = 'Sign in failed. Check your password and Secret Key. If you lost your Secret Key, use "Forgot password?" to reset your account and get a new one.'
|
|
} else if (detail.includes('initialization failed')) {
|
|
message = 'Authentication setup failed. Please try again or reset your password.'
|
|
} else {
|
|
message = getAuthErrorMessage(err)
|
|
}
|
|
set({ isLoading: false, isDerivingKeys: false, error: message })
|
|
throw err
|
|
}
|
|
},
|
|
|
|
completeUpgrade: async () => {
|
|
// Called after SRP registration completes during upgrade flow.
|
|
// The user already has a valid session cookie from the bcrypt login,
|
|
// so just fetch the profile to complete authentication. A full SRP
|
|
// login will happen naturally on their next session.
|
|
set({ isUpgrading: false, pendingUpgradeEmail: null, pendingUpgradePassword: null })
|
|
|
|
try {
|
|
const user = await authApi.me()
|
|
set({ user, isAuthenticated: true, isLoading: false, error: null })
|
|
} catch (err) {
|
|
set({ isLoading: false, error: getAuthErrorMessage(err) })
|
|
throw err
|
|
}
|
|
},
|
|
|
|
cancelUpgrade: () => {
|
|
set({
|
|
isUpgrading: false,
|
|
pendingUpgradeEmail: null,
|
|
pendingUpgradePassword: null,
|
|
isLoading: false,
|
|
})
|
|
},
|
|
|
|
logout: async () => {
|
|
keyStore.clearAll()
|
|
set({ isLoading: true })
|
|
try {
|
|
await authApi.logout()
|
|
} catch {
|
|
// ignore logout errors
|
|
} finally {
|
|
set({
|
|
user: null,
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
error: null,
|
|
needsSecretKey: false,
|
|
isDerivingKeys: false,
|
|
isUpgrading: false,
|
|
pendingUpgradeEmail: null,
|
|
pendingUpgradePassword: null,
|
|
})
|
|
}
|
|
},
|
|
|
|
checkAuth: async () => {
|
|
set({ isLoading: true })
|
|
try {
|
|
const user = await authApi.me()
|
|
set({ user, isAuthenticated: true, isLoading: false, error: null })
|
|
} catch {
|
|
set({ user: null, isAuthenticated: false, isLoading: false, error: null })
|
|
}
|
|
},
|
|
|
|
clearError: () => set({ error: null }),
|
|
clearNeedsSecretKey: () => set({ needsSecretKey: false }),
|
|
}))
|
|
|
|
// Role helpers
|
|
export function isSuperAdmin(user: UserMe | null): boolean {
|
|
return user?.role === 'super_admin'
|
|
}
|
|
|
|
export function isTenantAdmin(user: UserMe | null): boolean {
|
|
return user?.role === 'tenant_admin' || user?.role === 'super_admin'
|
|
}
|
|
|
|
export function isOperator(user: UserMe | null): boolean {
|
|
return (
|
|
user?.role === 'operator' ||
|
|
user?.role === 'tenant_admin' ||
|
|
user?.role === 'super_admin'
|
|
)
|
|
}
|
|
|
|
export function canWrite(user: UserMe | null): boolean {
|
|
return isOperator(user)
|
|
}
|
|
|
|
export function canDelete(user: UserMe | null): boolean {
|
|
return isTenantAdmin(user)
|
|
}
|