mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: implement web session handling and enhance token management
This commit is contained in:
@@ -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<Response>
|
||||
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<Response>
|
||||
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<Response>
|
||||
}
|
||||
|
||||
// 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<Response>
|
||||
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<Response>
|
||||
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<Resp
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
const token = String(body.token || '').trim();
|
||||
const token = String(body.token || '').trim() || (
|
||||
shouldUseWebSession(request)
|
||||
? (parseCookieValue(request, WEB_REFRESH_COOKIE) || '')
|
||||
: ''
|
||||
);
|
||||
if (token) {
|
||||
await storage.deleteRefreshToken(token);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
const baseResponse = new Response(null, { status: 200 });
|
||||
return shouldUseWebSession(request)
|
||||
? withWebRefreshCookie(request, baseResponse, null)
|
||||
: baseResponse;
|
||||
}
|
||||
|
||||
+2
-1
@@ -346,7 +346,8 @@ export interface TokenResponse {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
refresh_token: string;
|
||||
refresh_token?: string;
|
||||
web_session?: boolean;
|
||||
TwoFactorToken?: string;
|
||||
Key: string;
|
||||
PrivateKey: string | null;
|
||||
|
||||
+39
-8
@@ -15,12 +15,42 @@ const DEFAULT_CORS_HEADERS = [
|
||||
'X-Request-Email',
|
||||
'X-Device-Identifier',
|
||||
'X-Device-Name',
|
||||
'X-NodeWarden-Web-Session',
|
||||
];
|
||||
|
||||
function getAllowedOrigin(request: Request): string | null {
|
||||
function isExtensionOrigin(origin: string): boolean {
|
||||
return (
|
||||
origin.startsWith('chrome-extension://')
|
||||
|| origin.startsWith('moz-extension://')
|
||||
|| origin.startsWith('safari-web-extension://')
|
||||
);
|
||||
}
|
||||
|
||||
function isWildcardCorsPath(path: string): boolean {
|
||||
return (
|
||||
path.startsWith('/icons/')
|
||||
|| path === '/config'
|
||||
|| path === '/api/config'
|
||||
|| path === '/api/version'
|
||||
);
|
||||
}
|
||||
|
||||
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
|
||||
const url = new URL(request.url);
|
||||
const origin = request.headers.get('Origin');
|
||||
if (!origin) return '*';
|
||||
return origin;
|
||||
if (isWildcardCorsPath(url.pathname)) {
|
||||
return { allowOrigin: '*', allowCredentials: false };
|
||||
}
|
||||
if (!origin) {
|
||||
return { allowOrigin: null, allowCredentials: false };
|
||||
}
|
||||
if (origin === url.origin) {
|
||||
return { allowOrigin: origin, allowCredentials: true };
|
||||
}
|
||||
if (isExtensionOrigin(origin)) {
|
||||
return { allowOrigin: origin, allowCredentials: false };
|
||||
}
|
||||
return { allowOrigin: null, allowCredentials: false };
|
||||
}
|
||||
|
||||
function buildCorsHeaders(request: Request): Record<string, string> {
|
||||
@@ -35,13 +65,14 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
||||
'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';
|
||||
}
|
||||
|
||||
|
||||
+54
-3
@@ -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<AppPhase>(initialBootstrap.phase);
|
||||
const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [profile, setProfile] = useState<Profile | null>(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<ThemePreference>(() => readThemePreference());
|
||||
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
|
||||
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialProfileSnapshot?.key);
|
||||
|
||||
const [confirm, setConfirm] = useState<AppConfirmState | null>(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() {
|
||||
<AuthViews
|
||||
mode={phase}
|
||||
pendingAction={pendingAuthAction}
|
||||
unlockReady={!!profile}
|
||||
unlockReady={!!profile?.key && !!session}
|
||||
unlockPreparing={unlockPreparing}
|
||||
loginValues={loginValues}
|
||||
registerValues={registerValues}
|
||||
unlockPassword={unlockPassword}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface AuthViewsProps {
|
||||
mode: 'login' | 'register' | 'locked';
|
||||
pendingAction: 'login' | 'register' | 'unlock' | null;
|
||||
unlockReady: boolean;
|
||||
unlockPreparing: boolean;
|
||||
loginValues: LoginValues;
|
||||
registerValues: RegisterValues;
|
||||
unlockPassword: string;
|
||||
@@ -97,14 +98,17 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
type="button"
|
||||
className="auth-link-btn"
|
||||
onClick={props.onShowLockedPasswordHint}
|
||||
disabled={unlockBusy}
|
||||
disabled={unlockBusy || props.unlockPreparing}
|
||||
>
|
||||
{t('txt_show_password_hint')}
|
||||
</button>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || !props.unlockReady}>
|
||||
{props.unlockPreparing ? (
|
||||
<p className="muted standalone-muted">{t('txt_loading')}</p>
|
||||
) : null}
|
||||
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || props.unlockPreparing || !props.unlockReady}>
|
||||
<Unlock size={16} className="btn-icon" />
|
||||
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
|
||||
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
|
||||
</button>
|
||||
<div className="or">{t('txt_or')}</div>
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
|
||||
|
||||
+116
-19
@@ -10,8 +10,10 @@ import type {
|
||||
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
||||
|
||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||
const PROFILE_SNAPSHOT_KEY = 'nodewarden.web.profile-snapshot.v1';
|
||||
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
|
||||
const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
|
||||
const WEB_SESSION_HEADER = 'X-NodeWarden-Web-Session';
|
||||
|
||||
export interface PreloginResult {
|
||||
hash: string;
|
||||
@@ -26,6 +28,24 @@ export interface PreloginKdfConfig {
|
||||
kdfParallelism: number | null;
|
||||
}
|
||||
|
||||
interface PersistedSessionState {
|
||||
email: string;
|
||||
authMode: 'token' | 'web-cookie';
|
||||
}
|
||||
|
||||
interface RefreshFailure {
|
||||
ok: false;
|
||||
transient: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface RefreshSuccess {
|
||||
ok: true;
|
||||
token: TokenSuccess;
|
||||
}
|
||||
|
||||
type RefreshResult = RefreshFailure | RefreshSuccess;
|
||||
|
||||
function randomHex(length: number): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
||||
@@ -66,12 +86,19 @@ export function loadSession(): SessionState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as SessionState;
|
||||
if (!parsed.accessToken || !parsed.refreshToken) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<SessionState> & Partial<PersistedSessionState>;
|
||||
if (parsed.authMode === 'web-cookie' && parsed.email) {
|
||||
return {
|
||||
email: parsed.email,
|
||||
authMode: 'web-cookie',
|
||||
};
|
||||
}
|
||||
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
|
||||
return {
|
||||
accessToken: parsed.accessToken,
|
||||
refreshToken: parsed.refreshToken,
|
||||
email: parsed.email,
|
||||
authMode: 'token',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -83,14 +110,35 @@ export function saveSession(session: SessionState | null): void {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
return;
|
||||
}
|
||||
const persisted: SessionState = {
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken,
|
||||
const persisted: PersistedSessionState = {
|
||||
email: session.email,
|
||||
authMode: session.authMode === 'token' ? 'token' : 'web-cookie',
|
||||
};
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
|
||||
}
|
||||
|
||||
export function loadProfileSnapshot(email?: string | null): Profile | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Profile;
|
||||
if (!parsed?.email || !parsed?.key) return null;
|
||||
if (email && parsed.email !== email) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveProfileSnapshot(profile: Profile | null): void {
|
||||
if (!profile) return;
|
||||
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(profile));
|
||||
}
|
||||
|
||||
export function clearProfileSnapshot(): void {
|
||||
localStorage.removeItem(PROFILE_SNAPSHOT_KEY);
|
||||
}
|
||||
|
||||
export function getCurrentDeviceIdentifier(): string {
|
||||
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
||||
}
|
||||
@@ -170,7 +218,10 @@ export async function loginWithPassword(
|
||||
}
|
||||
const resp = await fetch('/identity/connect/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
[WEB_SESSION_HEADER]: '1',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||
@@ -183,18 +234,60 @@ export async function loginWithPassword(
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(refreshToken: string): Promise<TokenSuccess | null> {
|
||||
function isTransientRefreshStatus(status: number): boolean {
|
||||
return status === 0 || status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> {
|
||||
const body = new URLSearchParams();
|
||||
body.set('grant_type', 'refresh_token');
|
||||
body.set('refresh_token', refreshToken);
|
||||
const resp = await fetch('/identity/connect/token', {
|
||||
if (session.authMode !== 'web-cookie' && session.refreshToken) {
|
||||
body.set('refresh_token', session.refreshToken);
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/identity/connect/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const json = await parseJson<TokenError>(resp);
|
||||
return {
|
||||
ok: false,
|
||||
transient: isTransientRefreshStatus(resp.status),
|
||||
error: json?.error_description || json?.error || 'Session refresh failed',
|
||||
};
|
||||
}
|
||||
const json = await parseJson<TokenSuccess>(resp);
|
||||
if (!json?.access_token) {
|
||||
return { ok: false, transient: false, error: 'Session refresh failed' };
|
||||
}
|
||||
return { ok: true, token: json };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
transient: true,
|
||||
error: error instanceof Error ? error.message : 'Network error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeCurrentSession(session: SessionState | null): Promise<void> {
|
||||
const body = new URLSearchParams();
|
||||
if (session?.authMode !== 'web-cookie' && session?.refreshToken) {
|
||||
body.set('token', session.refreshToken);
|
||||
}
|
||||
await fetch('/identity/connect/revocation', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(session?.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const json = await parseJson<TokenSuccess>(resp);
|
||||
return json || null;
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function registerAccount(args: {
|
||||
@@ -279,18 +372,22 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
||||
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
||||
|
||||
let resp = await fetch(input, { ...init, headers });
|
||||
if (resp.status !== 401 || !session.refreshToken) return resp;
|
||||
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
||||
|
||||
const refreshed = await refreshAccessToken(session.refreshToken);
|
||||
if (!refreshed?.access_token) {
|
||||
const refreshed = await refreshAccessToken(session);
|
||||
if (!refreshed.ok) {
|
||||
if (refreshed.transient) {
|
||||
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
|
||||
}
|
||||
setSession(null);
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
|
||||
const nextSession: SessionState = {
|
||||
...session,
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token || session.refreshToken,
|
||||
accessToken: refreshed.token.access_token,
|
||||
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
||||
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
||||
};
|
||||
setSession(nextSession);
|
||||
saveSession(nextSession);
|
||||
|
||||
@@ -152,6 +152,7 @@ export async function createSend(
|
||||
const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp);
|
||||
const uploadUrl = uploadInfo?.url;
|
||||
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
|
||||
if (!session.accessToken) throw new Error('Unauthorized');
|
||||
const payload = new ArrayBuffer(encryptedFileBytes.byteLength);
|
||||
new Uint8Array(payload).set(encryptedFileBytes);
|
||||
const uploadResp = await uploadDirectEncryptedPayload({
|
||||
|
||||
@@ -240,6 +240,7 @@ export async function uploadCipherAttachment(
|
||||
const attachmentId = String(meta.attachmentId || '').trim();
|
||||
const uploadUrl = String(meta.url || '').trim();
|
||||
if (!attachmentId || !uploadUrl) throw new Error('Create attachment failed');
|
||||
if (!session.accessToken) throw new Error('Unauthorized');
|
||||
|
||||
const payload = new ArrayBuffer(encryptedBytes.byteLength);
|
||||
new Uint8Array(payload).set(encryptedBytes);
|
||||
|
||||
+47
-22
@@ -2,6 +2,7 @@ import {
|
||||
createAuthedFetch,
|
||||
deriveLoginHashLocally,
|
||||
getProfile,
|
||||
loadProfileSnapshot,
|
||||
loadSession,
|
||||
loginWithPassword,
|
||||
refreshAccessToken,
|
||||
@@ -26,6 +27,7 @@ export interface BootstrapAppResult {
|
||||
session: SessionState | null;
|
||||
profile: Profile | null;
|
||||
phase: AppPhase;
|
||||
needsBackgroundHydration?: boolean;
|
||||
}
|
||||
|
||||
export interface InitialAppBootstrapState {
|
||||
@@ -51,8 +53,9 @@ export interface RecoverTwoFactorResult {
|
||||
newRecoveryCode: string | null;
|
||||
}
|
||||
|
||||
function decodeJwtExp(accessToken: string): number | null {
|
||||
function decodeJwtExp(accessToken: string | undefined): number | null {
|
||||
try {
|
||||
if (!accessToken) return null;
|
||||
const parts = accessToken.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
@@ -66,23 +69,24 @@ function decodeJwtExp(accessToken: string): number | null {
|
||||
}
|
||||
|
||||
async function maybeRefreshSession(session: SessionState): Promise<SessionState | null> {
|
||||
if (!session.refreshToken) return session;
|
||||
if (!session.refreshToken && session.authMode !== 'web-cookie') return session.accessToken ? session : null;
|
||||
const exp = decodeJwtExp(session.accessToken);
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (exp !== null && exp - nowSeconds > 60) {
|
||||
if (session.accessToken && exp !== null && exp - nowSeconds > 60) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const refreshed = await refreshAccessToken(session.refreshToken);
|
||||
if (!refreshed?.access_token) {
|
||||
return exp !== null && exp > nowSeconds ? session : null;
|
||||
const refreshed = await refreshAccessToken(session);
|
||||
if (!refreshed.ok) {
|
||||
return session.accessToken && exp !== null && exp > nowSeconds ? session : null;
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token || session.refreshToken,
|
||||
accessToken: refreshed.token.access_token,
|
||||
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
||||
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,31 +201,51 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
||||
};
|
||||
}
|
||||
|
||||
const cachedProfile = loadProfileSnapshot(loaded.email);
|
||||
if (cachedProfile) {
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
jwtWarning: null,
|
||||
session: loaded,
|
||||
profile: cachedProfile,
|
||||
phase: 'locked',
|
||||
needsBackgroundHydration: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
jwtWarning: null,
|
||||
session: loaded,
|
||||
profile: null,
|
||||
phase: 'locked',
|
||||
needsBackgroundHydration: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function hydrateLockedSession(
|
||||
session: SessionState,
|
||||
fallbackProfile: Profile | null = null
|
||||
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||
const refreshedSession = await maybeRefreshSession(session);
|
||||
if (!refreshedSession?.accessToken) {
|
||||
return { session: null, profile: null };
|
||||
}
|
||||
try {
|
||||
const session = await maybeRefreshSession(loaded);
|
||||
if (!session) {
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
const profile = await getProfile(
|
||||
createAuthedFetch(
|
||||
() => session,
|
||||
() => refreshedSession,
|
||||
() => {}
|
||||
)
|
||||
);
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
jwtWarning: null,
|
||||
session,
|
||||
session: refreshedSession,
|
||||
profile,
|
||||
phase: 'locked',
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
jwtWarning: null,
|
||||
session: null,
|
||||
profile: null,
|
||||
phase: initial.phase === 'register' ? 'register' : 'login',
|
||||
session: refreshedSession,
|
||||
profile: fallbackProfile,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -236,6 +260,7 @@ export async function completeLogin(
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token,
|
||||
email: normalizedEmail,
|
||||
authMode: token.web_session ? 'web-cookie' : 'token',
|
||||
};
|
||||
const tempFetch = createAuthedFetch(
|
||||
() => baseSession,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export type AppPhase = 'register' | 'login' | 'locked' | 'app';
|
||||
|
||||
export interface SessionState {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
email: string;
|
||||
authMode?: 'token' | 'web-cookie';
|
||||
symEncKey?: string;
|
||||
symMacKey?: string;
|
||||
}
|
||||
@@ -265,7 +266,8 @@ export interface WebBootstrapResponse {
|
||||
|
||||
export interface TokenSuccess {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
refresh_token?: string;
|
||||
web_session?: boolean;
|
||||
expires_in?: number;
|
||||
token_type?: string;
|
||||
TwoFactorToken?: string;
|
||||
|
||||
Reference in New Issue
Block a user