fix: unify bookmark data sources and repair OAuth token management#5
Conversation
Comprehensive fix plan for expired OAuth tokens, data source split in bookmark pages, and architecture improvements to token management.
7 tasks covering centralized token manager, crypto API fix, data source unification, DRY refactoring, cron enhancement, admin protection, and build verification.
- Create centralized getTokenManager module (DRY: 6 files → 1) - Switch [slug]/page.js from raindrop.js to raindrop-with-auth.js - Fix deprecated crypto.createCipher → createCipheriv - Enhance cron error handling with needs_reauth classification - Add ADMIN_SECRET Basic Auth protection to admin page - Add L2 CLAUDE.md for auth module Resolves: bookmarks not displaying, expired OAuth tokens, duplicated token manager selection logic.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ef49dcb5f7
ℹ️ 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".
| const encrypted = encryptedData.slice((IV_LENGTH + TAG_LENGTH) * 2) | ||
|
|
||
| const decipher = crypto.createDecipher(ALGORITHM, key, iv) | ||
| const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) |
There was a problem hiding this comment.
Preserve legacy ciphertext during crypto migration
When this deploys over existing KV/Supabase/env tokens written by the old createCipher path, decrypt() now tries to authenticate them with createDecipheriv using the prepended IV, but the old API derived its own IV/key from the password and did not use that stored IV. Those existing encrypted access_token, refresh_token, and RAINDROP_ENCRYPTED_REFRESH_TOKEN values will fail GCM authentication, so token status, cron refresh, and bookmark fetches break until a full reauth/manual env replacement is done; the task also states the output format should stay compatible, so decrypt needs a fallback/migration path for the legacy format.
Useful? React with 👍 / 👎.
| if (!verifyBasicAuth(authorization)) { | ||
| return unauthorizedResponse() |
There was a problem hiding this comment.
Handle admin authentication outside the page render
When ADMIN_SECRET is set and the request is unauthenticated, this page returns a Response object from a React Server Component. App Router pages render React nodes rather than route-handler responses, so the unauthenticated path will render an invalid object/throw instead of sending the intended 401 with WWW-Authenticate; put this check in middleware/a route handler or use a supported page-level flow.
Useful? React with 👍 / 👎.
P1: Add legacy createCipher fallback in decrypt() for existing
encrypted tokens written by the old API.
P2: Replace Response return in Server Component with React node
rendering for unauthorized state.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c1477fc5ee
ℹ️ 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".
| try { | ||
| const decipher = crypto.createDecipher(ALGORITHM, key) | ||
| let decrypted = decipher.update(encryptedData, 'hex', 'utf8') | ||
| decrypted += decipher.final('utf8') |
There was a problem hiding this comment.
Preserve the legacy GCM auth tag
When existing tokens were encrypted by the old createCipher path, the stored value is still iv + tag + ciphertext because encrypt() prepended the random IV and GCM tag before this commit. This fallback now passes that whole blob as ciphertext and never calls setAuthTag() with the stored tag, so legacy access_token, refresh_token, and RAINDROP_ENCRYPTED_REFRESH_TOKEN values still fail authentication instead of decrypting; fresh evidence is that the new fallback at this line uses encryptedData directly rather than the parsed tag/ciphertext.
Useful? React with 👍 / 👎.
| export default async function RaindropSetupPage() { | ||
| const adminSecret = process.env.ADMIN_SECRET | ||
|
|
||
| // ADMIN_SECRET 未设置 → 开发环境向后兼容,直接放行 | ||
| if (adminSecret) { |
There was a problem hiding this comment.
Protect the OAuth token endpoints too
When ADMIN_SECRET is set, this guard only restricts rendering /admin/raindrop-setup; I checked the linked API routes (/api/auth/raindrop, /callback, /clear, and /status) and none of them reads ADMIN_SECRET or otherwise authenticates the caller. An unauthenticated internet user can still start the OAuth flow and replace the stored Raindrop tokens, or POST to /api/auth/raindrop/clear to delete them, so the new admin protection does not actually protect the token-management operations it exposes.
Useful? React with 👍 / 👎.
P1: Fix legacy crypto fallback to use only ciphertext portion
(skip stored IV/tag which were meaningless under old API).
P2: Add ADMIN_SECRET auth guard to OAuth API endpoints
(initiate, status, clear) via shared requireAdminAuth().
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b2f1ab42e0
ℹ️ 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".
| if (!authorization || !authorization.startsWith('Basic ')) { | ||
| return <UnauthorizedPage /> |
There was a problem hiding this comment.
Issue a Basic challenge before rendering unauthorized
When ADMIN_SECRET is set and a normal browser visits /admin/raindrop-setup without cached credentials, this renders the unauthorized React page instead of sending an HTTP 401 with a WWW-Authenticate challenge. Because the browser never gets a Basic-auth challenge, it has no way to prompt for or cache the password, so the admin setup flow remains unreachable unless the caller manually injects an Authorization header; put this guard in middleware/a route handler or otherwise send the challenge before rendering.
Useful? React with 👍 / 👎.
Add middleware-level 401 + WWW-Authenticate: Basic for /admin routes so browsers prompt native credential dialog instead of showing an inert unauthorized page.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3c4be7945d
ℹ️ 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".
| const decipher = crypto.createDecipher(ALGORITHM, key) | ||
| let decrypted = decipher.update(legacyCiphertext, 'hex', 'utf8') | ||
| decrypted += decipher.final('utf8') |
There was a problem hiding this comment.
Preserve the GCM tag in legacy decryption
For deployments that already have tokens encrypted before this change, the first createDecipheriv attempt fails because the legacy createCipher ignored the stored IV, and this fallback then tries to decrypt the legacy GCM ciphertext without calling setAuthTag(tag). AES-GCM still requires the saved auth tag, so final() throws and decrypt() returns null, making existing access/refresh tokens unusable until manual reauth.
Useful? React with 👍 / 👎.
| if (!auth || !auth.startsWith('Basic ')) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) |
There was a problem hiding this comment.
Challenge the API auth endpoints too
When ADMIN_SECRET is set, the setup UI authenticates on /admin/raindrop-setup but then fetches and links to /api/auth/raindrop/...; Basic auth credentials challenged for /admin/ are not reliably sent to the separate /api/ scope, and this 401 response does not include a WWW-Authenticate challenge. As a result, a valid admin can load the page but the status fetch, clear action, and OAuth start endpoint can all get stuck returning JSON 401 instead of prompting/succeeding.
Useful? React with 👍 / 👎.
P1: Remove legacy crypto fallback — old createCipher tokens cannot
be decrypted; expired tokens will trigger reauth naturally.
P2: Move API endpoint protection to middleware with same Basic Auth
realm so browser reuses cached credentials across /admin and /api.
Remove redundant requireAdminAuth() guards from API routes.
Delete unused require-admin-auth.js.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
@codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fc93ff910a
ℹ️ 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".
|
|
||
| const decipher = crypto.createDecipher(ALGORITHM, key, iv) | ||
| // --- 解密: createCipheriv (显式 IV + GCM auth tag) --- | ||
| const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) |
There was a problem hiding this comment.
Preserve decryption of existing tokens
When this deploys with tokens already stored by the previous encrypt() implementation, decrypt() now reads the random IV prefix and passes it to createDecipheriv, but the old createCipher/createDecipher path used password-based derivation and did not actually use that prefixed IV. As a result, every existing KV/Supabase/env access_token or refresh_token encrypted before this commit fails GCM authentication, so bookmark fetching and scheduled refreshes break until the owner manually reauthorizes; add a legacy-decrypt fallback/migration or explicitly clear and reauth those tokens.
Useful? React with 👍 / 👎.
| } | ||
| }, | ||
| '/admin/:path*', | ||
| '/api/auth/raindrop/:path((?!callback$).*)' |
There was a problem hiding this comment.
Protect the OAuth start route too
With ADMIN_SECRET set, this matcher still does not run for the bare /api/auth/raindrop endpoint (I checked the current Next matcher behavior: it matches /api/auth/raindrop/status but not /api/auth/raindrop). That leaves the OAuth start URL unauthenticated, so an outside user can get redirected with this app's client id and complete the unprotected callback to overwrite the stored Raindrop tokens with their own account; include the base route in the matcher while still excluding only the callback.
Useful? React with 👍 / 👎.
getBookmarks() can return null on non-auth errors. The [slug] page called .find() directly on the result, crashing Server Components render. Add null/empty checks before array operations.
decipher.final() throws GCM auth tag mismatch for tokens encrypted by the old createCipher API. Return null on failure so callers gracefully degrade to reauth flow instead of crashing.
Summary
[slug]/page.js从raindrop.js(静态token)切换到raindrop-with-auth.js(OAuth),消除分裂的数据获取路径getTokenManager()到src/lib/auth/get-token-manager.js,消除6处重复的选择逻辑(~88行)createCipher/createDecipher→createCipheriv/createDecipheriv,修复废弃 API{ success, needs_reauth }响应,区分 refresh_token 失效与临时错误/admin/raindrop-setup添加ADMIN_SECRETBasic Auth 验证(未设置时向后兼容)Root Cause
OAuth token 自2026年1月29日过期后,每日 cron 刷新因 refresh_token 同样失效而持续失败。
[slug]/page.js使用旧的静态 token 路径进一步加剧了问题。Test plan
npm run build通过npm run lint通过/bookmarks正常显示/bookmarks/[slug]正常显示书签详情/admin/raindrop-setup重新完成 OAuth 授权/api/cron/refresh-tokens返回结构化响应