diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 34371b9..41ecd45 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -18,6 +18,7 @@ import { const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_REMEMBER = 5; +const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh'; // Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code. // Keep request parsing backward-compatible with historical provider values (8 / 100). const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1'; @@ -31,6 +32,54 @@ function resolveTotpSecret(userSecret: string | null): string | null { return null; } +function shouldUseWebSession(request: Request): boolean { + return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1'; +} + +function parseCookieValue(request: Request, name: string): string | null { + const rawCookie = String(request.headers.get('Cookie') || '').trim(); + if (!rawCookie) return null; + for (const part of rawCookie.split(';')) { + const [key, ...rest] = part.trim().split('='); + if (key !== name) continue; + const value = rest.join('=').trim(); + return value ? decodeURIComponent(value) : null; + } + return null; +} + +function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string { + const isHttps = new URL(request.url).protocol === 'https:'; + const parts = [ + `${WEB_REFRESH_COOKIE}=${encodeURIComponent(refreshToken)}`, + 'Path=/identity/connect', + 'HttpOnly', + 'SameSite=Strict', + `Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`, + ]; + if (isHttps) parts.push('Secure'); + return parts.join('; '); +} + +function buildClearedRefreshCookie(request: Request): string { + return buildRefreshCookie(request, '', 0); +} + +function withWebRefreshCookie(request: Request, response: Response, refreshToken: string | null): Response { + const headers = new Headers(response.headers); + headers.append( + 'Set-Cookie', + refreshToken + ? buildRefreshCookie(request, refreshToken, Math.floor(LIMITS.auth.refreshTokenTtlMs / 1000)) + : buildClearedRefreshCookie(request) + ); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + function buildPreloginResponse( email: string, kdfType: number, @@ -283,7 +332,7 @@ export async function handleToken(request: Request, env: Env): Promise access_token: accessToken, expires_in: LIMITS.auth.accessTokenTtlSeconds, token_type: 'Bearer', - refresh_token: refreshToken, + ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }), ...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}), Key: user.key, PrivateKey: user.privateKey, @@ -305,7 +354,10 @@ export async function handleToken(request: Request, env: Env): Promise userDecryptionOptions: buildUserDecryptionOptions(user), }; - return jsonResponse(response); + const baseResponse = jsonResponse(response); + return shouldUseWebSession(request) + ? withWebRefreshCookie(request, baseResponse, refreshToken) + : baseResponse; } else if (grantType === 'send_access') { const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute); @@ -371,14 +423,21 @@ export async function handleToken(request: Request, env: Env): Promise } // Refresh token - const refreshToken = body.refresh_token; + const refreshToken = String(body.refresh_token || '').trim() || ( + shouldUseWebSession(request) + ? parseCookieValue(request, WEB_REFRESH_COOKIE) + : null + ); if (!refreshToken) { return identityErrorResponse('Refresh token is required', 'invalid_request', 400); } const result = await auth.refreshAccessToken(refreshToken); if (!result) { - return identityErrorResponse('Invalid refresh token', 'invalid_grant', 400); + const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400); + return shouldUseWebSession(request) + ? withWebRefreshCookie(request, invalidResponse, null) + : invalidResponse; } // Keep a short overlap window for old refresh token to absorb @@ -395,7 +454,7 @@ export async function handleToken(request: Request, env: Env): Promise access_token: accessToken, expires_in: LIMITS.auth.accessTokenTtlSeconds, token_type: 'Bearer', - refresh_token: newRefreshToken, + ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }), Key: user.key, PrivateKey: user.privateKey, AccountKeys: buildAccountKeys(user), @@ -416,7 +475,10 @@ export async function handleToken(request: Request, env: Env): Promise userDecryptionOptions: buildUserDecryptionOptions(user), }; - return jsonResponse(response); + const baseResponse = jsonResponse(response); + return shouldUseWebSession(request) + ? withWebRefreshCookie(request, baseResponse, newRefreshToken) + : baseResponse; } return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400); @@ -470,10 +532,17 @@ export async function handleRevocation(request: Request, env: Env): Promise { @@ -35,13 +65,14 @@ function buildCorsHeaders(request: Request): Record { 'Access-Control-Allow-Headers': allowHeaders.join(', '), 'Access-Control-Expose-Headers': '*', 'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds), - 'Access-Control-Allow-Private-Network': 'true', }; - const allowedOrigin = getAllowedOrigin(request); - if (allowedOrigin) { - headers['Access-Control-Allow-Origin'] = allowedOrigin; - headers['Access-Control-Allow-Credentials'] = 'true'; + const corsPolicy = getCorsPolicy(request); + if (corsPolicy.allowOrigin) { + headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin; + if (corsPolicy.allowCredentials) { + headers['Access-Control-Allow-Credentials'] = 'true'; + } headers['Vary'] = 'Origin, Access-Control-Request-Headers'; } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index ba2576a..5e89afe 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -10,8 +10,12 @@ import JwtWarningPage from '@/components/JwtWarningPage'; import { createAuthedFetch, getAuthorizedDevices, + clearProfileSnapshot, getCurrentDeviceIdentifier, getPasswordHint, + loadProfileSnapshot, + saveProfileSnapshot, + revokeCurrentSession, getTotpStatus, saveSession, } from '@/lib/api/auth'; @@ -39,6 +43,7 @@ import { performRecoverTwoFactorLogin, performRegistration, performTotpLogin, + hydrateLockedSession, performUnlock, type JwtUnsafeReason, type PendingTotp, @@ -135,11 +140,12 @@ function resolveSystemTheme(): 'light' | 'dark' { export default function App() { const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []); const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []); + const initialProfileSnapshot = useMemo(() => loadProfileSnapshot(initialBootstrap.session?.email), [initialBootstrap]); const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null); const [location, navigate] = useLocation(); const [phase, setPhase] = useState(initialBootstrap.phase); const [session, setSessionState] = useState(initialBootstrap.session); - const [profile, setProfile] = useState(null); + const [profile, setProfile] = useState(initialProfileSnapshot); const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations); const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(initialBootstrap.jwtWarning); @@ -172,6 +178,7 @@ export default function App() { const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' }); const [themePreference, setThemePreference] = useState(() => readThemePreference()); const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme()); + const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialProfileSnapshot?.key); const [confirm, setConfirm] = useState(null); const [mobileLayout, setMobileLayout] = useState(false); @@ -273,6 +280,16 @@ export default function App() { window.localStorage.setItem(THEME_STORAGE_KEY, themePreference); }, [themePreference]); + useEffect(() => { + saveProfileSnapshot(profile); + }, [profile]); + + useEffect(() => { + if (phase === 'locked' && profile?.key && session) { + setUnlockPreparing(false); + } + }, [phase, profile, session]); + useEffect(() => installMagneticUiFeedback(), []); function handleToggleTheme() { @@ -334,6 +351,7 @@ export default function App() { setSession(boot.session); setProfile(boot.profile); setPhase(boot.phase); + setUnlockPreparing(boot.phase === 'locked' && !boot.profile?.key); })(); return () => { @@ -341,9 +359,34 @@ export default function App() { }; }, [initialBootstrap]); + useEffect(() => { + if (phase !== 'locked' || !session) return; + let cancelled = false; + void (async () => { + const result = await hydrateLockedSession(session, profile); + if (cancelled) return; + if (!result.session) { + setSession(null); + setProfile(null); + setUnlockPreparing(false); + setPhase('login'); + if (location !== '/login') navigate('/login'); + return; + } + setSession(result.session); + if (result.profile) { + setProfile(result.profile); + } + })(); + return () => { + cancelled = true; + }; + }, [phase, session?.email, location, navigate]); + async function finalizeLogin(login: CompletedLogin) { setSession(login.session); setProfile(login.profile); + setUnlockPreparing(false); setPendingTotp(null); setTotpCode(''); setPhase('app'); @@ -528,6 +571,7 @@ export default function App() { const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations); setSession(nextSession); setUnlockPassword(''); + setUnlockPreparing(false); setPhase('app'); if (location === '/' || location === '/lock') navigate('/vault'); pushToast('success', t('txt_unlocked')); @@ -544,14 +588,18 @@ export default function App() { delete nextSession.symEncKey; delete nextSession.symMacKey; setSession(nextSession); + setUnlockPreparing(false); setPhase('locked'); navigate('/lock'); } function logoutNow() { + void revokeCurrentSession(sessionRef.current); setConfirm(null); setSession(null); + clearProfileSnapshot(); setProfile(null); + setUnlockPreparing(false); setPendingTotp(null); setPhase('login'); navigate('/login'); @@ -882,9 +930,11 @@ export default function App() { const connect = () => { if (disposed) return; + const accessToken = session.accessToken; + if (!accessToken) return; try { const hubUrl = new URL('/notifications/hub', window.location.origin); - hubUrl.searchParams.set('access_token', session.accessToken); + hubUrl.searchParams.set('access_token', accessToken); hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:'; socket = new WebSocket(hubUrl.toString()); } catch { @@ -1198,7 +1248,8 @@ export default function App() { {t('txt_show_password_hint')} -
{t('txt_or')}