diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 17fe05f..f66784d 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -163,6 +163,30 @@ function withWebRefreshCookie(request: Request, response: Response, refreshToken }); } +async function revokePresentedAccessTokenSession(request: Request, env: Env, storage: StorageService): Promise { + const authHeader = request.headers.get('Authorization'); + if (!authHeader) return; + + const auth = new AuthService(env); + const verified = await auth.verifyAccessTokenWithUser(authHeader); + if (!verified) return; + + const deviceIdentifier = String(verified.payload.did || '').trim(); + if (deviceIdentifier) { + const nextSessionStamp = generateUUID(); + await storage.rotateDeviceSessionStamp(verified.user.id, deviceIdentifier, nextSessionStamp); + await storage.deleteRefreshTokensByDevice(verified.user.id, deviceIdentifier); + AuthService.invalidateDeviceCache(verified.user.id, deviceIdentifier); + return; + } + + verified.user.securityStamp = generateUUID(); + verified.user.updatedAt = new Date().toISOString(); + await storage.saveUser(verified.user); + await storage.deleteRefreshTokensByUserId(verified.user.id); + AuthService.invalidateUserCache(verified.user.id); +} + function buildPreloginResponse( email: string, kdfType: number, @@ -1010,6 +1034,11 @@ export async function handlePrelogin(request: Request, env: Env): Promise { const storage = new StorageService(env.DB); + try { + await revokePresentedAccessTokenSession(request, env, storage); + } catch { + // RFC 7009 revocation is best-effort and should not reveal token state. + } let body: Record; const contentType = request.headers.get('content-type') || ''; diff --git a/src/services/storage-device-repo.ts b/src/services/storage-device-repo.ts index 3bd33fa..bdd4f8c 100644 --- a/src/services/storage-device-repo.ts +++ b/src/services/storage-device-repo.ts @@ -97,6 +97,20 @@ export async function touchDeviceLastSeen( return Number(result.meta.changes ?? 0) > 0; } +export async function rotateDeviceSessionStamp( + db: D1Database, + userId: string, + deviceIdentifier: string, + sessionStamp: string +): Promise { + const now = new Date().toISOString(); + const result = await db + .prepare('UPDATE devices SET session_stamp = ?, updated_at = ? WHERE user_id = ? AND device_identifier = ?') + .bind(sessionStamp, now, userId, deviceIdentifier) + .run(); + return Number(result.meta.changes ?? 0) > 0; +} + export async function updateDeviceKeys( db: D1Database, userId: string, diff --git a/src/services/storage.ts b/src/services/storage.ts index 473f392..ae92062 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -109,6 +109,7 @@ import { isKnownDevice as getKnownStoredDevice, isKnownDeviceByEmail as getKnownStoredDeviceByEmail, saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken, + rotateDeviceSessionStamp as rotateStoredDeviceSessionStamp, touchDeviceLastSeen as touchStoredDeviceLastSeen, upsertDevice as saveStoredDevice, updateDeviceName as updateStoredDeviceName, @@ -761,6 +762,10 @@ export class StorageService { return findStoredDevice(this.db, userId, deviceIdentifier); } + async rotateDeviceSessionStamp(userId: string, deviceIdentifier: string, sessionStamp: string): Promise { + return rotateStoredDeviceSessionStamp(this.db, userId, deviceIdentifier, sessionStamp); + } + async updateDeviceKeys( userId: string, deviceIdentifier: string, diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index 8cd895c..81a9e3f 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -402,6 +402,7 @@ export async function revokeCurrentSession(session: SessionState | null): Promis method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', + ...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}), ...(session?.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}), }, body: body.toString(),