Repository navigation
fix(auth): device persistence improvements and auth loop prevention - #223
Conversation
This PR addresses issues found in code review and adds comprehensive device token persistence for web browsers. ## Auth Loop Prevention - Add `authFailedPermanently` flag to break Zustand rehydration loops - Add `authAttemptTimestamps` for rapid auth attempt detection (5+ in 10s) - Clear failure flags on successful session load (OAuth/login flow fix) - Set permanent failure flag when device token is definitively revoked ## Device Token Linking - Link device tokens to refresh tokens in OAuth callback and One Tap - Ensures device revocation also revokes associated refresh tokens - Prevents revoked devices from refreshing via orphaned refresh tokens ## Web Device Token Persistence - Extend device token creation to web platform (was desktop-only) - Pass deviceId and deviceName from browser to OAuth endpoints - Store web device token in localStorage for 90-day persistence - Add fingerprint utility integration for web device identification ## Desktop Auth Improvements - Reduce JWT cache TTL from 30s to 5s for freshness after rotation - Clear `authFailedPermanently` on login attempt in useAuth hook ## Tests - Add comprehensive tests for auth loop detection - Add tests for web device token creation in One Tap and OAuth callback - Add tests for `authFailedPermanently` flag behavior Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. 📝 WalkthroughWalkthroughAdds device-aware sign‑in: client-sent Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant Client
participant SigninRoute as Signin Route
participant OAuthProvider as Google OAuth
participant CallbackRoute as OAuth Callback Route
participant DeviceSvc as Device Token Service
participant DB as Database
participant ClientRedirect as Client Redirect
Client->>SigninRoute: POST /api/auth/google/signin (platform, deviceId, deviceName, returnUrl)
SigninRoute->>OAuthProvider: initiate OAuth (signed state includes deviceId/deviceName/returnUrl)
OAuthProvider-->>Client: redirect to callback URL with state
Client->>CallbackRoute: GET /api/auth/google/callback (state)
CallbackRoute->>CallbackRoute: verify state, isSafeReturnUrl
alt web + deviceId present
CallbackRoute->>DeviceSvc: validateOrCreateDeviceToken(userId, deviceId, deviceName, ip, ua)
DeviceSvc->>DB: insert/ensure device token (expires ~90d)
DB-->>DeviceSvc: deviceToken + recordId
DeviceSvc-->>CallbackRoute: deviceToken
CallbackRoute->>DB: update refreshTokens WHERE userId AND tokenHash SET deviceTokenId
end
CallbackRoute->>ClientRedirect: build redirect URL (auth=success [+ deviceToken])
CallbackRoute-->>Client: 302 Redirect
Client->>Client: persist deviceToken (localStorage / Electron secure store)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@apps/web/src/app/api/auth/google/__tests__/google-callback-redirect.test.ts`:
- Line 120: Remove the unused imports causing ESLint failures: delete the
`users` specifier from the import from `@pagespace/db` and remove the
`resetDistributedRateLimit` import wherever it’s declared in this test file
(`google-callback-redirect.test.ts`); if either is intended to be used, instead
reference them in the test (e.g., invoke `resetDistributedRateLimit()` or use
`users` fixture) otherwise simply remove the unused import entries to satisfy
the linter.
In `@apps/web/src/components/auth/GoogleOneTap.tsx`:
- Around line 86-98: The localStorage write for deviceToken can throw and should
not abort a successful sign-in; wrap the localStorage.setItem call (in the
branch where !isDesktop && data.deviceToken) in a try/catch (or call a safe
helper like safeSetItem) so any storage error is caught, logged (or reported)
but not rethrown, and the flow continues to the redirect; keep the existing
Electron path (window.electron.auth.storeSession) unchanged and only apply the
guard around the web storage write for data.deviceToken.
🧹 Nitpick comments (1)
apps/web/src/stores/__tests__/useAuthStore.test.ts (1)
875-898: Persistence tests may have unreliable assertions.These tests check
mockLocalStorage.getItem('auth-storage')immediately aftersetState, but Zustand's persist middleware may not have written to storage synchronously. The conditionalif (stored)masks potential failures if the assertion never runs.Consider using Zustand's
persistAPI to trigger/verify persistence explicitly, or await the storage write:// More reliable approach: it('given authFailedPermanently true, should be included in persisted state', async () => { useAuthStore.setState({ authFailedPermanently: true }); // Allow persist middleware to complete await new Promise(resolve => setTimeout(resolve, 0)); const stored = mockLocalStorage.getItem('auth-storage'); expect(stored).toBeTruthy(); const parsed = JSON.parse(stored!); expect(parsed.state.authFailedPermanently).toBe(true); });
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1bedbfe064
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Pass device token to client via URL (client will store in localStorage) | ||
| redirectUrl.searchParams.set('deviceToken', deviceTokenValue); |
There was a problem hiding this comment.
Prevent leaking deviceToken via unvalidated returnUrl
The new redirectUrl.searchParams.set('deviceToken', ...) appends a long‑lived device token to the redirect URL, but redirectUrl is built from returnUrl in the OAuth state without any origin validation. That means an attacker can call /api/auth/google/signin with an external returnUrl and chosen deviceId, get a signed state, and trick a victim into completing Google OAuth; the callback will then redirect to the attacker’s domain with the victim’s deviceToken. Since /api/auth/device/refresh accepts deviceToken + deviceId to mint new access/refresh tokens, this can lead to account takeover. Consider restricting returnUrl to same‑origin paths (or stripping deviceToken for external redirects / storing it server‑side) to avoid leaking the token.
Useful? React with 👍 / 👎.
The useAuth hook calls useAuthStore.getState().setAuthFailedPermanently() during login, but the test mock was missing this method causing CI failures. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Prevent attackers from hijacking OAuth callbacks to external domains: - Validate returnUrl in signin to reject absolute/protocol-relative URLs - Defense-in-depth: re-validate returnUrl in callback before redirect - Add localStorage error handling in GoogleOneTap for private browsing - Add comprehensive tests for redirect protection Attack vector prevented: attacker sets returnUrl to external domain, tricks victim through OAuth, captures deviceToken from redirect. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Code reviewFound 1 issue:
PageSpace/apps/web/src/app/api/auth/google/signin/route.ts Lines 30 to 34 in 8fcb014 Compare with the correct implementation in callback/route.ts: PageSpace/apps/web/src/app/api/auth/google/callback/route.ts Lines 31 to 37 in 8fcb014 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Wrap decodeURIComponent() in try-catch to handle malformed percent-encoding gracefully. Without this, URLs like /%E0%A4%A would throw URIError instead of being rejected as unsafe. This matches the pattern already used in callback/route.ts for defense-in-depth. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Summary
authFailedPermanentlyflag on successful OAuth/session loadCode Review Follow-up Fixes
Issue 1: authFailedPermanently not cleared on successful session load
File:
useAuthStore.ts:332-354When
response.oksucceeds inloadSession(), theset()calls now clear bothauthFailedPermanentlyandauthAttemptTimestamps. This fixes the scenario where a user:authFailedPermanently: true)loadSession)true, causing logout on next auth checkIssue 2 & 3: OAuth callback and One Tap don't link device token to refresh token
Files:
callback/route.ts:347-358,one-tap/route.ts:325-336The refresh token is inserted BEFORE the device token is created. Now after creating the device token, we update the refresh token row to link it:
This ensures when a user revokes a device via Connected Devices, the associated refresh token is also deleted.
Other Changes (from other devs)
Web Device Token Persistence
deviceIdanddeviceNamesignin/page.tsx,GoogleOneTap.tsx,signin/route.tsDesktop Auth Improvements
authFailedPermanentlyon login attempt inuseAuth.tsAuth Loop Prevention
authFailedPermanentlyflag (persisted) to break Zustand rehydration loopsauthAttemptTimestampsfor rapid auth attempt detectiononRehydrateStoragehandler to clear stale auth stateTest plan
authFailedPermanently: truein localStorage, login via OAuth, verify flag is cleareddevice_token_idis NOT NULL inrefresh_tokenstablepnpm vitest run src/stores/__tests__/useAuthStore.test.ts(46 tests)pnpm vitest run src/app/api/auth/(335 auth tests total)🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Tests
✏️ Tip: You can customize this high-level summary in your review settings.