Skip to content

feat: account linking #130

Merged
gaboesquivel merged 4 commits intomainfrom
link-accounts
Mar 10, 2026
Merged

feat: account linking #130
gaboesquivel merged 4 commits intomainfrom
link-accounts

Conversation

@gaboesquivel
Copy link
Copy Markdown
Member

@gaboesquivel gaboesquivel commented Mar 10, 2026

Summary by CodeRabbit

  • New Features

    • Change-email flow: request + verify via code or link; session rotation and notification on success
    • OAuth account linking/unlinking (GitHub, Facebook, Twitter) with per-user rate limits and last-sign-in guardrails
    • UI and client hooks for change-email and linked-accounts management; session now surfaces linked accounts
    • New email templates for change confirmation and notification
  • Documentation

    • Added architecture docs covering account linking, verification modes, flows, and guardrails

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
basilic-docs Ready Ready Preview, Comment Mar 10, 2026 6:25am
basilic-fastify Ready Ready Preview, Comment Mar 10, 2026 6:25am
basilic-next Ready Ready Preview, Comment Mar 10, 2026 6:25am

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 10, 2026

Warning

Rate limit exceeded

@gaboesquivel has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 2 minutes and 18 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b102acd1-7953-4c59-9ee7-c49f1e714129

📥 Commits

Reviewing files that changed from the base of the PR and between de7c554 and 414838c.

📒 Files selected for processing (5)
  • apps/docu/content/docs/architecture/authentication.mdx
  • apps/fastify/src/lib/oauth-exchange-state.ts
  • apps/fastify/src/routes/account/email/change/request.ts
  • apps/fastify/src/routes/auth/oauth/github/exchange.ts
  • apps/next/app/(dashboard)/settings/(profile)/change-email-block.tsx

Walkthrough

Adds account-linking: change-email flows and OAuth provider linking/unlinking. Introduces DB verification types and consumedAt, guardrail utilities, backend routes for request/verify/link/unlink, frontend components/hooks, email templates, OpenAPI updates, and OAuth state validation/consumption.

Changes

Cohort / File(s) Summary
Documentation
apps/docu/content/docs/architecture/account-linking.mdx, apps/docu/content/docs/architecture/authentication.mdx, apps/docu/content/docs/architecture/index.mdx, apps/docu/content/docs/architecture/meta.json
New account-linking docs and architecture updates describing change-email and OAuth linking flows, verification modes, guardrails, and references.
Database Schema
apps/fastify/src/db/schema/tables/auth-attempts.ts, apps/fastify/src/db/schema/tables/verification.ts
Added 'change_email' to auth_attempts.type; added 'change_email' and 'oauth_link_state' verification types, optional meta.userId, and consumed_at column.
OAuth State & Guardrails Utilities
apps/fastify/src/lib/oauth-exchange-state.ts, apps/fastify/src/lib/auth-guardrails.ts, apps/fastify/src/lib/email.ts
New validateAndConsumeOAuthState utility with link-mode handling; hasRemainingLoginMethod guardrail to prevent unlinking last sign-in method; normalizeEmail helper.
OAuth Implementations / Refactor
apps/fastify/src/lib/oauth-twitter.ts
Refactored Twitter exchange to accept options object, extracted runTwitterExchangeTxForUser, and added linkUserId handling path.
Backend Routes — Email Change
apps/fastify/src/routes/account/email/change/request.ts, apps/fastify/src/routes/account/email/change/verify.ts
New authenticated endpoints to request change-email and verify via code or verificationId; rate limits, token hashing, transactional email/update, session refresh rotation, and notifications to old email.
Backend Routes — OAuth Linking
apps/fastify/src/routes/auth/oauth/*/link-authorize-url.ts, apps/fastify/src/routes/auth/oauth/*/exchange.ts, apps/fastify/src/routes/account/link/oauth/unlink.ts
New per-provider link-authorize endpoints (GitHub/Facebook/Twitter) with rate limits and PKCE where applicable; exchange endpoints updated to validate/consume link state, support link mode, return optional redirectTo, and handle PROVIDER_ALREADY_LINKED conflicts; unlink route enforces LAST_SIGN_IN_METHOD guardrail.
Session / Public API
apps/fastify/src/routes/auth/session/user.ts, apps/fastify/openapi/openapi.json
Session user response now includes linkedAccounts; large OpenAPI manifest updates reflecting new/renamed endpoints, response schemas, and new verification types/fields.
Frontend — Next.js UI & Callbacks
apps/next/app/(dashboard)/settings/(profile)/change-email-block.tsx, .../linked-accounts-section.tsx, .../profile-section.tsx, apps/next/app/auth/callback/change-email/route.ts, apps/next/app/auth/callback/oauth/*/route.ts, apps/next/lib/auth/callback-utils.ts
New profile UI components for change-email and linked accounts, callback handler for change-email, OAuth callback code adjusted to use dynamic redirectTo via getOAuthRedirectTarget, and client cookie/token update logic.
Frontend — Hooks / Exports
packages/react/src/hooks/use-change-email.ts, use-oauth-link.ts, use-oauth-unlink.ts, packages/react/src/index.ts
New hooks for requesting/verifying change-email and linking/unlinking OAuth providers; exported from package index.
Email Templates
packages/email/emails/change-email.tsx, packages/email/emails/email-changed-notification.tsx
New email templates for change-email verification and notification to previous email address.
Linting / Config
apps/fastify/eslint.config.mjs, apps/next/eslint.config.js
ESLint overrides adjusted for OAuth exchange files and profile component line limits.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI
    participant Backend
    participant DB
    participant Email
    participant Session

    User->>UI: Request email change (new email)
    UI->>Backend: POST /account/email/change/request {email, callbackUrl}
    Backend->>DB: ensure rate limit, check email conflict
    Backend->>DB: create verification (token hash, verificationId)
    Backend->>Email: send change-email (code + link)
    Email-->>User: deliver code/link
    User->>UI: Submit code/link
    UI->>Backend: POST /account/email/change/verify {token or verificationId}
    Backend->>DB: validate verification, check expiry, delete/consume record
    Backend->>DB: transaction: update user.email, mark verified, rotate refresh token
    Backend->>Session: issue new access/refresh tokens
    Backend->>Email: send notification to old email (if changed)
    Session-->>UI: return tokens
Loading
sequenceDiagram
    participant User
    participant UI
    participant LinkAuth
    participant OAuthProvider
    participant FrontendCallback
    participant ExchangeRoute
    participant DB

    User->>UI: Click "Link Provider"
    UI->>LinkAuth: GET /auth/oauth/{provider}/link-authorize-url
    LinkAuth->>DB: rate-limit check, insert oauth_link_state (state hash, meta.userId, codeVerifier)
    LinkAuth-->>UI: return provider authorize URL with raw state
    UI->>OAuthProvider: Redirect user to provider with state
    OAuthProvider-->>FrontendCallback: redirect back with code & state
    FrontendCallback->>ExchangeRoute: POST /auth/oauth/{provider}/exchange {code, state}
    ExchangeRoute->>DB: validateAndConsumeOAuthState(stateHash) -> isLinkMode, linkUserId
    ExchangeRoute->>DB: check PROVIDER_ALREADY_LINKED and fetch/create target user
    ExchangeRoute->>OAuthProvider: exchange code for tokens
    ExchangeRoute->>DB: upsert account row for provider, associate to user
    ExchangeRoute->>DB: delete or mark state consumed
    ExchangeRoute-->>FrontendCallback: return tokens + redirectTo
    FrontendCallback->>UI: redirect to redirectTo (e.g., /settings?linked=ok)
    UI->>DB: refresh session/user to show new linked provider
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰 I hopped along the auth-lined trail,
Sent codes and states in a tiny mail,
Linked GitHub, Twitter, or Facebook too,
Old email warned — the change is new! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: account linking' accurately summarizes the main objective of the changeset, which implements comprehensive account linking functionality including OAuth provider linking, email change flows, and related guardrails.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch link-accounts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

🧹 Nitpick comments (4)
apps/next/eslint.config.js (1)

6-9: Consider if this override is necessary.

Per coding guidelines, UI components are exempt from the 300-line file size limit. Since profile-section.tsx is a UI component, this explicit override may be redundant. However, keeping it provides explicit documentation of expected file size.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/next/eslint.config.js` around lines 6 - 9, The ESLint override in
eslint.config.js specifically targets
'app/(dashboard)/settings/(profile)/profile-section.tsx' to relax the
'max-lines' rule; remove this override entry to avoid redundant special-casing
(since UI components are exempt from the 300-line limit), or alternatively
preserve it but replace it with a clearer approach: either broaden the files
pattern to match all UI components (e.g., a directory pattern) or add an inline
comment explaining why 'profile-section.tsx' needs the explicit 'max-lines'
override; target the override object that contains the files array and the
'max-lines' rule to apply the chosen change.
apps/next/app/auth/callback/oauth/github/route.ts (1)

30-35: Consider a shared OAuth callback success helper.

This redirect construction plus cookie-setting is now repeated across the GitHub, Facebook, and Twitter callback routes. A small helper for the success path would reduce drift in redirect/cookie behavior as more providers or callback variants get added.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/next/app/auth/callback/oauth/github/route.ts` around lines 30 - 35, The
redirect + cookie-setting block used in OAuth callbacks is duplicated; extract
it into a shared helper (e.g., handleOAuthCallbackSuccess or
sendOAuthSuccessRedirect) that accepts the Request/Response context, the
provider response, and tokens, constructs the redirect using
NextResponse.redirect(new URL(getOAuthRedirectTarget(response), request.url),
303), calls setAuthCookiesOnResponse on that response with tokens, and returns
the redirect Response; then replace the duplicated code in the GitHub, Facebook,
and Twitter callback routes with a single call to this helper to centralize
behavior and reduce drift.
apps/next/app/(dashboard)/settings/(profile)/linked-accounts-section.tsx (1)

20-25: Align the rendered provider list with the provider map.

providerLabels and the unlink path already account for google, but configuredProviders only renders GitHub/Facebook/Twitter. If Google-linked accounts can already show up in linkedAccounts, users won't be able to manage them here; otherwise the extra Google plumbing is just dead state. Consider deriving the rendered list from a single typed source of truth.

Also applies to: 60-60

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/next/app/`(dashboard)/settings/(profile)/linked-accounts-section.tsx
around lines 20 - 25, The rendered provider list is out of sync with
providerLabels (Google present in providerLabels/unlink logic but not in
configuredProviders); fix by deriving the UI list from a single source of truth
such as Object.keys(providerLabels) (or a typed enum) instead of hard-coded
configuredProviders so all providers (including 'google') are rendered
consistently; update the rendering logic that uses configuredProviders and the
unlink handler (the unlink path code and any checks against linkedAccounts) to
accept and handle 'google', and adjust types for
configuredProviders/linkedAccounts to include 'google' so the map/filter and
unlink functions correctly show and remove Google-linked accounts.
apps/fastify/src/routes/auth/oauth/github/exchange.ts (1)

98-225: Extract the new link-resolution block into a shared helper.

This addition pushes the route past the 300-line cap and duplicates most of the provider-link resolution now living in the other OAuth exchange handlers. Pull the state validation and user/account resolution into a shared helper before the providers drift.

As per coding guidelines, "LINTING: File size limit is 300 lines (exempt: test files and UI components)".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/fastify/src/routes/auth/oauth/github/exchange.ts` around lines 98 - 225,
The route now contains duplicated state validation and user/account resolution
logic (isLinkMode, linkUserId, deletion of verification, token/user/email
handling, existingAccount checks, user creation using randomUUID and
generateFunnyUsername) which pushes the file >300 lines; extract that block into
a shared helper (e.g., resolveOAuthStateAndUser or resolveAndLinkOAuthAccount)
that accepts the db, stateRecord (or state id), provider identifier ('github'),
accountId, ghUser/email and returns { user, existingAccount, isLinkMode } or
throws/replies on errors; replace the in-place logic in exchange.ts with a call
to that helper and reuse it from other OAuth exchange handlers to remove
duplication and keep files under the linting size limit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/docu/content/docs/architecture/authentication.mdx`:
- Line 361: The summary of callback pages is stale: update the listed OAuth
callback route in the sentence that currently enumerates
`/auth/callback/magiclink`, `/auth/callback/change-email`,
`/auth/callback/oauth/github`, `/auth/callback/web3`, `/auth/callback/passkey`
so it no longer hard-codes only GitHub; replace `/auth/callback/oauth/github`
with a generic `/auth/callback/oauth/[provider]` (or explicitly enumerate all
currently supported providers such as `/auth/callback/oauth/github`,
`/auth/callback/oauth/facebook`, `/auth/callback/oauth/twitter`) and ensure the
rest of the sentence (and the parallel mention of `/auth/logout`) remains
unchanged.
- Line 327: The docs incorrectly state that GET
/auth/oauth/{github,facebook,twitter}/link-authorize-url is available to "Bearer
— JWT or API key" clients, but the handler reads request.session.user.id
(session-only), so update the bullet to indicate session-based auth only (e.g.,
"Session required — cookie/JWT session (no API key)") and change the auth note
on that route to "Session" instead of "Bearer (JWT or API key)"; ensure the
route entry for GET /auth/oauth/.../link-authorize-url returns { redirectUrl }
and keeps the same rate limit but clearly documents that API-key clients cannot
initiate the flow.

In `@apps/fastify/eslint.config.mjs`:
- Around line 6-18: Remove the per-file ESLint overrides from eslint.config.mjs
(the block that lists src/routes/auth/oauth/*/exchange.ts and
src/lib/oauth-twitter.ts) and restore the repo default max-lines/max-params
rules; refactor the large route handlers (exchange.ts files) and the oauth
helper (oauth-twitter.ts) by extracting shared logic into smaller helper
functions/modules (e.g., create separate oauth/token-parsing,
oauth/provider-client, and oauth/error-handling utilities) so each file falls
under the 300-line limit and no per-file lint exemptions are required.

In `@apps/fastify/src/routes/account/email/change/request.ts`:
- Around line 105-135: Wrap the render() and
fastify.emailProvider.emails.send(...) calls in a try/catch and, on any error,
delete the previously inserted verification row (match by verificationId and
type 'change_email' and identifier) using the same db instance before rethrowing
the error; ensure you perform the cleanup whether render() fails or email
sending fails so orphaned rows don't count toward the rate limit, and keep the
insertion of tokenPlain conditional behavior unchanged.

In `@apps/fastify/src/routes/auth/oauth/facebook/exchange.ts`:
- Around line 77-82: The exchange path currently accepts verification.type
'oauth_link_state' and, after loading stateRecord.meta.userId, issues JWTs
without validating the current session; to fix this require the active session
to match the linked user before honoring link mode: when handling
oauth_link_state (and in the same logic branches referenced at lines 98-99,
183-221, 290-295), check request.session (or session.userId) equals
stateRecord.meta.userId (linkUserId) and reject the exchange if not;
alternatively only allow oauth_link_state through if request.session exists and
matches linkUserId, otherwise treat it as invalid state and deny/token error.
Ensure the verification.where clause (verification.type) still distinguishes
'oauth_state' vs 'oauth_link_state' but gate the link flow with the session
equality check before issuing fresh JWTs.

In `@apps/fastify/src/routes/auth/oauth/facebook/link-authorize-url.ts`:
- Around line 55-65: The current per-hour cap in link-authorize-url.ts is
counting live verification rows (query using verification, oauth_link_state and
identifier `link:${userId}`) but those rows are deleted by the exchange handler
so the limit can be bypassed; change the design to use a durable attempt log or
mark consumed attempts instead of deleting them: add or reuse a persistent
table/column (e.g., verification_attempts or add consumedAt on verification) and
record an immutable attempt entry for each start (or set consumedAt but keep the
row until window expires), then change the counting query (the code that
computes recentCount and uses linkAuthorizeUrlPerUserPerHour) to count those
durable attempt records within the one-hour window so completed flows don't
remove entries used for rate-limiting.

In `@apps/fastify/src/routes/auth/oauth/github/exchange.ts`:
- Around line 77-82: When handling verification.type === 'oauth_link_state' (the
logic that reads stateRecord.meta.userId into linkUserId) ensure the callback is
bound to the current session: check request.session?.user.id === linkUserId and
fail/redirect if not, and do not proceed to mint JWTs or create a fresh login
session from that code path; instead return a link-confirmation/redirect
response that requires the existing session to complete linking. Apply this same
session-check and no-token-minting restriction to every branch that handles
'oauth_link_state' (the blocks that resolve linkUserId from
stateRecord.meta.userId and then fall through to create the normal JWT response)
so minting functions (e.g., anything that creates JWTs/tokens or sets a
logged-in session) are never called for link-state handling unless the session
user id matches linkUserId.

In `@apps/fastify/src/routes/auth/oauth/twitter/exchange.ts`:
- Around line 98-100: Detect when stateRecord.type === 'oauth_link_state' and
ensure stateRecord.meta?.userId is present before consuming the DB row; if
meta.userId is missing, immediately return/throw INVALID_STATE and do not call
db.delete(verification).where(eq(verification.id, stateRecord.id)). Concretely,
modify the logic around isLinkMode and linkUserId to validate linkUserId and
short-circuit with INVALID_STATE when absent (before calling db.delete or
invoking runTwitterExchangeTx), so runTwitterExchangeTx only ever receives a
valid linkUserId for link flows.

In `@apps/fastify/src/routes/auth/oauth/twitter/link-authorize-url.ts`:
- Around line 62-72: The hourly-cap check currently counts only live
verification rows of type 'oauth_link_state' (recentCount) which gets deleted on
successful exchange, allowing bypass; change the design so attempts are
append-only or consumed rows are retained: either (A) create/ use an append-only
table (e.g., link_attempts) and switch the counting query in the link-authorize
flow to count rows in that table for identifier `link:${userId}`, and insert a
new attempt row on state creation (and on completion), or (B) stop deleting
verification rows in the exchange handler and instead update a consumed
flag/timestamp on the verification row (modify the exchange handler that deletes
oauth_link_state to perform an UPDATE to set consumedAt/consumed=true and
optionally insert an audit row), then update the count query (recentCount) to
count both consumed and unconsumed verification rows created within the hour;
update functions referenced: the code creating the state (link-authorize-url)
and the exchange handler that currently deletes the state (exchange.ts) to
implement the chosen approach.

In `@apps/fastify/src/routes/auth/session/user.ts`:
- Around line 88-92: The query fetching accountRows via
db.select(...).from(account).where(eq(account.userId, userId)) can return
multiple rows per providerId; deduplicate providerId values before assigning
linkedAccounts so the response contains unique providers only. Modify the
mapping in the block that defines linkedAccounts (using accountRows) to produce
a unique list—e.g., collect providerId into a Set or group by providerId, then
map to objects like { providerId }—so linkedAccounts contains one entry per
provider.

In `@apps/next/app/`(dashboard)/settings/(profile)/change-email-block.tsx:
- Around line 32-35: The handleVerify callback currently awaits
changeEmail.verify(...) but swallows rejections; wrap the call in a try/catch
inside handleVerify to catch errors from changeEmail.verify({ token:
code.trim(), email: newEmail.trim()}) and display failure feedback (e.g., call
the existing toast/error utility or set a local error state used by the
component). Specifically, modify the handleVerify function to validate inputs,
try { await changeEmail.verify(...) ; proceed on success } catch (err) { show a
toast.error or setError(err.message || 'Verification failed') } so the user sees
a clear error when the code is invalid or expired.

In `@apps/next/app/auth/callback/change-email/route.ts`:
- Around line 32-37: The createClient is using JWT mode with a no-op
onTokensRefreshed so refreshed tokens are lost; implement onTokensRefreshed to
accept the new tokens and store them in a variable (e.g.,
refreshedAuthToken/refreshedRefreshToken) accessible to the route handler, then
when returning either the success or error redirect response apply those
refreshed tokens to the response (set the cookies or headers) instead of the
original authToken/refreshToken; update the onTokensRefreshed callback in the
createClient call to capture tokens and ensure the final Response returned by
the route uses those captured tokens when setting cookies.

In `@packages/react/src/hooks/use-change-email.ts`:
- Around line 58-62: The onSuccess handler calls config?.onVerifySuccess?.(data)
but does not await it, causing
queryClient.invalidateQueries(['auth','session','user']) and
['auth','session','jwt'] to run against stale cookies; make the onSuccess
callback async and await the result of config.onVerifySuccess (i.e., await
config?.onVerifySuccess?.(data)) before calling queryClient.invalidateQueries so
the cookie/write performed in the caller (e.g., change-email-block) completes
first.
- Around line 65-71: The requestChange function currently swallows all errors
from requestMutation.mutateAsync, causing callers like ChangeEmailBlock to treat
failures as successes; modify requestChange (in use-change-email.ts) to not
silently catch errors—either remove the try/catch so the mutateAsync error
propagates, or catch and re-throw the caught error (or return a typed { success:
boolean, error?: Error } result and update callers to branch on it); ensure the
function's signature and callers (e.g., ChangeEmailBlock) are updated
accordingly so 409/429/provider failures are handled as failures rather than
advancing the UI.

In `@packages/react/src/hooks/use-oauth-link.ts`:
- Around line 19-22: The call to endpoints[provider]() in use-oauth-link is
swallowing HTTP errors and causing a misleading validation error; update the
invocation to pass throwOnError: true so the API client throws on non-2xx
responses (e.g., change the call to endpoints[provider]({ throwOnError: true })
or equivalent) and let that error surface instead of proceeding to the
redirectUrl validation; keep the rest of the validation (url check and error
message) as-is.

---

Nitpick comments:
In `@apps/fastify/src/routes/auth/oauth/github/exchange.ts`:
- Around line 98-225: The route now contains duplicated state validation and
user/account resolution logic (isLinkMode, linkUserId, deletion of verification,
token/user/email handling, existingAccount checks, user creation using
randomUUID and generateFunnyUsername) which pushes the file >300 lines; extract
that block into a shared helper (e.g., resolveOAuthStateAndUser or
resolveAndLinkOAuthAccount) that accepts the db, stateRecord (or state id),
provider identifier ('github'), accountId, ghUser/email and returns { user,
existingAccount, isLinkMode } or throws/replies on errors; replace the in-place
logic in exchange.ts with a call to that helper and reuse it from other OAuth
exchange handlers to remove duplication and keep files under the linting size
limit.

In `@apps/next/app/`(dashboard)/settings/(profile)/linked-accounts-section.tsx:
- Around line 20-25: The rendered provider list is out of sync with
providerLabels (Google present in providerLabels/unlink logic but not in
configuredProviders); fix by deriving the UI list from a single source of truth
such as Object.keys(providerLabels) (or a typed enum) instead of hard-coded
configuredProviders so all providers (including 'google') are rendered
consistently; update the rendering logic that uses configuredProviders and the
unlink handler (the unlink path code and any checks against linkedAccounts) to
accept and handle 'google', and adjust types for
configuredProviders/linkedAccounts to include 'google' so the map/filter and
unlink functions correctly show and remove Google-linked accounts.

In `@apps/next/app/auth/callback/oauth/github/route.ts`:
- Around line 30-35: The redirect + cookie-setting block used in OAuth callbacks
is duplicated; extract it into a shared helper (e.g., handleOAuthCallbackSuccess
or sendOAuthSuccessRedirect) that accepts the Request/Response context, the
provider response, and tokens, constructs the redirect using
NextResponse.redirect(new URL(getOAuthRedirectTarget(response), request.url),
303), calls setAuthCookiesOnResponse on that response with tokens, and returns
the redirect Response; then replace the duplicated code in the GitHub, Facebook,
and Twitter callback routes with a single call to this helper to centralize
behavior and reduce drift.

In `@apps/next/eslint.config.js`:
- Around line 6-9: The ESLint override in eslint.config.js specifically targets
'app/(dashboard)/settings/(profile)/profile-section.tsx' to relax the
'max-lines' rule; remove this override entry to avoid redundant special-casing
(since UI components are exempt from the 300-line limit), or alternatively
preserve it but replace it with a clearer approach: either broaden the files
pattern to match all UI components (e.g., a directory pattern) or add an inline
comment explaining why 'profile-section.tsx' needs the explicit 'max-lines'
override; target the override object that contains the files array and the
'max-lines' rule to apply the chosen change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e9d01a78-809a-4a5d-8c9e-abaa3f08c273

📥 Commits

Reviewing files that changed from the base of the PR and between d9cc19c and 9fa6f1a.

⛔ Files ignored due to path filters (5)
  • packages/core/src/api-client.gen.ts is excluded by !**/*.gen.ts
  • packages/core/src/api-wrapper.gen.ts is excluded by !**/*.gen.ts
  • packages/core/src/gen/index.ts is excluded by !**/gen/**, !**/gen/**
  • packages/core/src/gen/sdk.gen.ts is excluded by !**/gen/**, !**/gen/**, !**/*.gen.ts
  • packages/core/src/gen/types.gen.ts is excluded by !**/gen/**, !**/gen/**, !**/*.gen.ts
📒 Files selected for processing (36)
  • apps/docu/content/docs/architecture/account-linking.mdx
  • apps/docu/content/docs/architecture/authentication.mdx
  • apps/docu/content/docs/architecture/index.mdx
  • apps/docu/content/docs/architecture/meta.json
  • apps/fastify/eslint.config.mjs
  • apps/fastify/openapi/openapi.json
  • apps/fastify/src/db/schema/tables/auth-attempts.ts
  • apps/fastify/src/db/schema/tables/verification.ts
  • apps/fastify/src/lib/auth-guardrails.ts
  • apps/fastify/src/lib/email.ts
  • apps/fastify/src/lib/oauth-twitter.ts
  • apps/fastify/src/routes/account/email/change/request.ts
  • apps/fastify/src/routes/account/email/change/verify.ts
  • apps/fastify/src/routes/account/link/oauth/unlink.ts
  • apps/fastify/src/routes/auth/oauth/facebook/exchange.ts
  • apps/fastify/src/routes/auth/oauth/facebook/link-authorize-url.ts
  • apps/fastify/src/routes/auth/oauth/github/exchange.ts
  • apps/fastify/src/routes/auth/oauth/github/link-authorize-url.ts
  • apps/fastify/src/routes/auth/oauth/twitter/exchange.ts
  • apps/fastify/src/routes/auth/oauth/twitter/link-authorize-url.ts
  • apps/fastify/src/routes/auth/session/user.ts
  • apps/next/app/(dashboard)/settings/(profile)/change-email-block.tsx
  • apps/next/app/(dashboard)/settings/(profile)/linked-accounts-section.tsx
  • apps/next/app/(dashboard)/settings/(profile)/profile-section.tsx
  • apps/next/app/auth/callback/change-email/route.ts
  • apps/next/app/auth/callback/oauth/facebook/route.ts
  • apps/next/app/auth/callback/oauth/github/route.ts
  • apps/next/app/auth/callback/oauth/twitter/route.ts
  • apps/next/eslint.config.js
  • apps/next/lib/auth/callback-utils.ts
  • packages/email/emails/change-email.tsx
  • packages/email/emails/email-changed-notification.tsx
  • packages/react/src/hooks/use-change-email.ts
  • packages/react/src/hooks/use-oauth-link.ts
  • packages/react/src/hooks/use-oauth-unlink.ts
  • packages/react/src/index.ts

- Docs: callback routes enum (github,facebook,twitter), link-authorize-url session-only
- Change email request: try/catch with verification cleanup on render/send error
- OAuth link: session validation for oauth_link_state, pass Bearer from callback
- Durable rate limit: consumedAt on verification, update instead of delete for oauth_link_state
- Session user: deduplicate linkedAccounts by providerId
- Change email block: try/catch for request/verify, toast on error
- Change email callback: onTokensRefreshed to capture refreshed tokens
- use-change-email: await onVerifySuccess, propagate requestChange errors
- use-oauth-link: throwOnError for API client
- Extract validateAndConsumeOAuthState helper, remove max-lines override
- linked-accounts-section: derive providers from providerLabels, Google coming soon
- Twitter exchange: validate linkUserId before consume
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
apps/docu/content/docs/architecture/authentication.mdx (1)

136-139: ⚠️ Potential issue | 🟡 Minor

Keep the narrative section session-only here too.

This paragraph still describes GET /auth/oauth/{provider}/link-authorize-url as (Bearer) / Bearer required, but the endpoint table at Line 327 and the route handlers require an authenticated session user, not an API key. Please mirror the “session/JWT required, no API key” wording here so the two sections don’t contradict each other.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/docu/content/docs/architecture/authentication.mdx` around lines 136 -
139, Update the narrative to match the endpoint table and route handlers by
changing the authentication wording for GET
/auth/oauth/{provider}/link-authorize-url from "(Bearer)" to "session/JWT
required, no API key" (or similar session-only phrasing); keep the rest of the
flow description (oauth_link_state stored with meta.userId, Exchange logic
branching on oauth_link_state vs oauth_state, link mode using meta.userId, and
409 PROVIDER_ALREADY_LINKED behavior) unchanged so the document is consistent
with the route handlers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/fastify/src/lib/oauth-exchange-state.ts`:
- Around line 25-33: The current select and later consume flow (stateRecord from
verification where verification.value = stateHash and type in
['oauth_state','oauth_link_state']) is racy and allows double-use; modify the
load-and-consume to be atomic by including consumedAt IS NULL in the initial
WHERE (i.e., only select records with consumedAt == null) and then perform the
consume as an UPDATE that sets consumedAt (and any other consumed metadata) with
a WHERE that includes the same id/value AND consumedAt IS NULL (or do both
actions inside a transaction and use RETURNING or check affected row count) so
that concurrent requests cannot both succeed; update the logic around the
consumption code that writes consumedAt (the later block around lines 71-76) to
check the UPDATE affected rows and fail if zero to ensure single-use.

In `@apps/fastify/src/routes/account/email/change/request.ts`:
- Around line 83-91: The handler currently lets a user request an email change
to the same address because the check only rejects when another user has that
email; add a server-side no-op guard: after fetching existingByEmail (and/or
loading the current user by userId), if normalizedEmail equals the current
user's email (i.e., existingByEmail && existingByEmail.id === userId or compare
against fetched currentUser.email), return an error (e.g., 400 with code
'EMAIL_NOT_CHANGED' and a clear message) and do not create the change_email
verification or send mail; update the logic around existingByEmail,
normalizedEmail and userId to short-circuit in that case.

In `@apps/fastify/src/routes/auth/oauth/github/exchange.ts`:
- Around line 187-200: The GitHub exchange path (the select -> insert -> select
block using users, variable u, randomUUID(), and generateFunnyUsername()) is
racy and must be made idempotent: replace the current create flow with the same
find-or-create flow used by the Facebook exchange or perform an upsert+reselect
(insert ... onConflict do nothing or update, then reselect by email or conflict
key) so concurrent callbacks cannot create duplicate users; ensure you still
generate a username when inserting and after the upsert re-query the users table
(eq(users.email, email) or eq(users.id, newUserId)) and throw only if the final
select returns no user.

In `@apps/next/app/`(dashboard)/settings/(profile)/change-email-block.tsx:
- Around line 50-57: The inputs rely only on placeholders so they lack
programmatic labels; add accessible names by giving each Input a stable id and
either a visible <label htmlFor="..."> or an aria-label/aria-labelledby prop.
Concretely, update the verification code Input (the one with value={code} and
onChange={e => setCode(...)}) to include id="verification-code" and a matching
<label> or aria-label="Verification code (6 digits)"; do the same for the
new-email Input (the one handling the new email, e.g., value/email and its
setter) with id="new-email" and label or aria-label="New email address" so
screen readers get a stable name.

---

Duplicate comments:
In `@apps/docu/content/docs/architecture/authentication.mdx`:
- Around line 136-139: Update the narrative to match the endpoint table and
route handlers by changing the authentication wording for GET
/auth/oauth/{provider}/link-authorize-url from "(Bearer)" to "session/JWT
required, no API key" (or similar session-only phrasing); keep the rest of the
flow description (oauth_link_state stored with meta.userId, Exchange logic
branching on oauth_link_state vs oauth_state, link mode using meta.userId, and
409 PROVIDER_ALREADY_LINKED behavior) unchanged so the document is consistent
with the route handlers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5af0eb48-54d8-4292-9358-40c0b33b1b4b

📥 Commits

Reviewing files that changed from the base of the PR and between 9fa6f1a and de7c554.

⛔ Files ignored due to path filters (3)
  • apps/fastify/src/db/migrations/0016_easy_chameleon.sql is excluded by !apps/fastify/src/db/migrations/**
  • apps/fastify/src/db/migrations/meta/0016_snapshot.json is excluded by !apps/fastify/src/db/migrations/**
  • apps/fastify/src/db/migrations/meta/_journal.json is excluded by !apps/fastify/src/db/migrations/**
📒 Files selected for processing (18)
  • apps/docu/content/docs/architecture/authentication.mdx
  • apps/fastify/eslint.config.mjs
  • apps/fastify/src/db/schema/tables/verification.ts
  • apps/fastify/src/lib/oauth-exchange-state.ts
  • apps/fastify/src/routes/account/email/change/request.ts
  • apps/fastify/src/routes/auth/oauth/facebook/exchange.ts
  • apps/fastify/src/routes/auth/oauth/github/exchange.ts
  • apps/fastify/src/routes/auth/oauth/twitter/exchange.ts
  • apps/fastify/src/routes/auth/session/user.ts
  • apps/next/app/(dashboard)/settings/(profile)/change-email-block.tsx
  • apps/next/app/(dashboard)/settings/(profile)/linked-accounts-section.tsx
  • apps/next/app/auth/callback/change-email/route.ts
  • apps/next/app/auth/callback/oauth/facebook/route.ts
  • apps/next/app/auth/callback/oauth/github/route.ts
  • apps/next/app/auth/callback/oauth/twitter/route.ts
  • apps/next/eslint.config.js
  • packages/react/src/hooks/use-change-email.ts
  • packages/react/src/hooks/use-oauth-link.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/next/app/auth/callback/change-email/route.ts
  • apps/next/app/auth/callback/oauth/github/route.ts

- oauth-exchange-state: atomic consume with consumedAt IS NULL to prevent double-use
- account/email/change: add EMAIL_NOT_CHANGED guard when new email equals current
- github exchange: use findOrCreateUserByEmail for idempotent user creation
- change-email-block: add id and aria-label to verification code and new-email inputs
- docu: align link-authorize-url auth wording (session/JWT, no API key)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant