Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughAdds connected-accounts feature: backend CRUD and routes for listing accounts and issuing per-account access tokens (with validation, refresh, and rotation), client/server SDK APIs and implementations with caches and object-based account lookup, demo UI, known error, and extensive E2E tests. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant Client
participant ServerAPI
participant BackendHandler
participant TenancyDB
participant OAuthProvider
Client->>ServerAPI: POST /connected-accounts/me/{provider}/{account}/access-token (scope)
ServerAPI->>BackendHandler: forward request with session & tenancy
BackendHandler->>TenancyDB: validate user, find connected account & access tokens
alt valid access token present
TenancyDB-->>BackendHandler: return valid access token
BackendHandler-->>ServerAPI: return access token
ServerAPI-->>Client: 200 { accessToken }
else no valid access token
BackendHandler->>TenancyDB: fetch refresh tokens for account
BackendHandler->>OAuthProvider: attempt refresh using refresh token
alt refresh success
OAuthProvider-->>BackendHandler: new access token (+ optional refresh token)
BackendHandler->>TenancyDB: create access token record, rotate refresh token if needed
BackendHandler-->>ServerAPI: return access token
ServerAPI-->>Client: 200 { accessToken }
else refresh failed
OAuthProvider-->>BackendHandler: error
BackendHandler->>TenancyDB: mark token invalid
BackendHandler-->>ServerAPI: return OAuth error
ServerAPI-->>Client: error response
end
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
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 |
Greptile OverviewGreptile SummaryThis PR expands the “connected accounts” feature to support multiple accounts per OAuth provider. Key changes:
Main required fixes are around route-handler conventions (SmartRouteHandler requirement) and demo async handler pattern (runAsynchronouslyWithAlert rule). Confidence Score: 3/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant Client as SDK Client (User)
participant ClientIF as StackClientInterface
participant API as Backend /api/latest
participant ConnList as GET /connected-accounts/:user_id
participant TokenEP as POST /connected-accounts/:user_id/:provider_id/:provider_account_id/access-token
participant Prisma as Prisma DB
participant Provider as OAuth Provider
Client->>ClientIF: listConnectedAccounts(session)
ClientIF->>API: GET /connected-accounts/me
API->>ConnList: connectedAccountCrudHandlers.listHandler
ConnList->>Prisma: findMany(projectUserOAuthAccount)
Prisma-->>ConnList: oauth accounts (allowConnectedAccounts=true)
ConnList-->>ClientIF: { items: [{provider, provider_account_id, user_id}], is_paginated:false }
ClientIF-->>Client: OAuthConnection[]
Client->>ClientIF: createProviderAccessTokenByAccount(providerId, providerAccountId, scope)
ClientIF->>API: POST /connected-accounts/me/{providerId}/{providerAccountId}/access-token
API->>TokenEP: connectedAccountAccessTokenByAccountCrudHandlers.createHandler
TokenEP->>Prisma: findMany(oAuthAccessToken)
alt Existing valid access token
TokenEP->>Provider: checkAccessTokenValidity(accessToken)
Provider-->>TokenEP: valid
TokenEP-->>ClientIF: { access_token }
else Need refresh
TokenEP->>Prisma: findMany(oAuthToken)
TokenEP->>Provider: getAccessToken(refreshToken, scope)
Provider-->>TokenEP: TokenSet(accessToken, expiresAt, refreshToken?)
TokenEP->>Prisma: create(oAuthAccessToken)
opt Refresh token rotated
TokenEP->>Prisma: update(old oAuthToken invalid)
TokenEP->>Prisma: create(new oAuthToken)
end
TokenEP-->>ClientIF: { access_token }
end
ClientIF-->>Client: { access_token }
|
There was a problem hiding this comment.
Pull request overview
This PR adds support for connecting multiple OAuth accounts from the same provider to a single user, addressing a limitation where users could only link one account per OAuth provider (e.g., one Google account, one GitHub account).
Changes:
- Adds new API methods (
listConnectedAccounts(),linkConnectedAccount(),getOrLinkConnectedAccount()) to manage multiple connected accounts - Extends
getConnectedAccount()to accept{ provider, providerAccountId }for precise account lookup alongside the legacy provider-only lookup - Updates the
Connectiontype withproviderandproviderAccountIdfields, deprecating theidfield for backward compatibility - Implements backend endpoints for listing connected accounts and retrieving access tokens by specific account IDs
- Adds comprehensive E2E tests and a demo page
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
packages/template/src/lib/stack-app/users/index.ts |
Adds type definitions for new connected account methods with deprecation markers for legacy APIs |
packages/template/src/lib/stack-app/connected-accounts/index.ts |
Extends Connection type with provider and providerAccountId fields, deprecates id field |
packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts |
Implements server-side connected account methods with caching and overloaded signatures |
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts |
Implements client-side connected account methods including OAuth flow initiation |
packages/stack-shared/src/interface/server-interface.ts |
Adds server interface methods for listing accounts and getting access tokens by account ID |
packages/stack-shared/src/interface/client-interface.ts |
Adds client interface methods for listing accounts and getting access tokens by account ID |
packages/stack-shared/src/interface/crud/connected-accounts.ts |
Defines CRUD schemas for connected accounts list and read operations |
apps/backend/src/app/api/latest/connected-accounts/[user_id]/route.tsx |
Implements GET endpoint for listing user's connected accounts |
apps/backend/src/app/api/latest/connected-accounts/[user_id]/crud.tsx |
Implements connected accounts list handler with proper authorization checks |
apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/[provider_account_id]/access-token/route.tsx |
Implements POST endpoint for getting access tokens by specific account |
apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/[provider_account_id]/access-token/crud.tsx |
Implements access token retrieval with refresh token handling |
examples/demo/src/app/connected-accounts/page.tsx |
Adds comprehensive demo page showcasing all new connected account APIs |
apps/e2e/tests/js/connected-accounts.test.ts |
Adds 40+ E2E tests covering various scenarios including multiple accounts from same provider |
apps/e2e/tests/backend/endpoints/api/v1/connected-accounts.test.ts |
Adds 20+ backend endpoint tests for connected accounts API |
apps/dev-launchpad/public/index.html |
Unrelated change attempting to add subdomain routing (contains syntax error) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts
Show resolved
Hide resolved
...test/connected-accounts/[user_id]/[provider_id]/[provider_account_id]/access-token/route.tsx
Show resolved
Hide resolved
There was a problem hiding this comment.
Actionable comments posted: 11
🤖 Fix all issues with AI agents
In
`@apps/backend/src/app/api/latest/connected-accounts/`[user_id]/[provider_id]/[provider_account_id]/access-token/crud.tsx:
- Around line 155-179: The refresh-token rotation is non-atomic: the sequence
around prisma.oAuthAccessToken.create, prisma.oAuthToken.update and
prisma.oAuthToken.create can race under concurrent getAccessToken calls; wrap
the rotation (invalidate old refresh token and insert new refresh token and
access token) in a single database transaction (using prisma.$transaction) or
use an optimistic lock (e.g., check and update a version/isValid predicate in
the WHERE clause) when calling prisma.oAuthToken.update so only one caller can
mark the old token invalid and create the new token; ensure the transaction
includes the access token insert and the refresh-token update/create to
guarantee atomicity and avoid duplicate refresh tokens.
In `@apps/dev-launchpad/public/index.html`:
- Line 368: The href's nested template mistakenly includes a literal "$p" prefix
in the subdomain construction (the expression `$p${stackPortPrefix}.`) which
results in invalid hostnames; update the href template so the conditional uses
either an empty string or the plain port-prefix subdomain without the stray "$"
(i.e., replace `$p${stackPortPrefix}.` with `${stackPortPrefix}.` or otherwise
remove the leading "$"), leaving the rest of the expression
(withPrefix(app.portSuffix), app.path, and the importance-based class logic)
unchanged.
In `@apps/e2e/tests/backend/endpoints/api/v1/connected-accounts.test.ts`:
- Around line 898-905: The test titled "should return 404 when trying to list
connected accounts for non-existent user via server access" is misleading
because it asserts response.status is 400 and response.body.code is
"SCHEMA_ERROR"; change the test description string to reflect the actual
behavior (e.g., "should return 400 when trying to list connected accounts for
non-existent user via server access") so the test title matches the assertions
in the it(...) block that calls niceBackendFetch and checks response.status and
response.body.code.
- Around line 877-896: The test title is incorrect: update the it(...)
description in the test block where the request to
"/api/v1/connected-accounts/me" is made (the test starting with "should return
403 when unauthenticated user tries to list connected accounts") to reflect the
actual behavior/status (400 and CANNOT_GET_OWN_USER_WITHOUT_USER) — e.g., change
the string to "should return 400 when unauthenticated user tries to list
connected accounts" so the test title matches the assertion and response.
In `@examples/demo/src/app/connected-accounts/page.tsx`:
- Around line 47-49: The displayed access token always gets "..." appended even
when its length is ≤ 50; update the rendering logic around accessToken (the
variable used in the JSX pre block) to conditionally append the ellipsis only
when accessToken.length > 50 (e.g., show accessToken.slice(0,50) + "..." when
longer, otherwise show the full accessToken without the suffix) so short tokens
are not misleadingly truncated.
In `@packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts`:
- Around line 1424-1443: getConnectedAccount currently computes scopeString but
ignores it in the object-based branch; either remove scopes from that overload
or forward scopeString when building/returning the OAuthConnection. Locate the
object-branch in getConnectedAccount and when you resolve the connected account
(where the code currently returns found), call the same token-fetch/creation
path used elsewhere but pass options?.scopes (or scopeString) into the
token/cache call (e.g., into the token-fetch cache or
_createOAuthConnectionFromCrudItem invocation) so the requested scopes are
honored; alternatively, remove the scopes option from the object-based overload
signature at the top to prevent callers from passing scopes that will be
ignored. Ensure the unique symbols getConnectedAccount and scopeString are
updated accordingly.
- Around line 1526-1534: The bare catch around existing.getAccessToken()
swallows all errors; change it to catch (err) and only handle the expected
token-invalid/revoked error (e.g., check err.name/err.message for the OAuth
"invalid_grant"/"invalid_token"/"refresh token" signatures or a specific
AssertionError), allowing other errors (network/timeouts, server errors) to be
rethrown or logged and propagated; implement a small predicate like
isTokenInvalidError(err) and use it inside the catch to decide whether to fall
through to the link flow or rethrow, and ensure you reference the existing
variable and its getAccessToken method when applying this change.
- Around line 1541-1546: In useOrLinkConnectedAccount, remove the unsafe type
cast and non-null assertion: avoid casting provider with "as ProviderType" and
replace the "result!" assertion with a defensive null-check (e.g., return result
?? throwErr("useOrLinkConnectedAccount: cache returned null") ). If you cannot
immediately reconcile string vs ProviderType for
app._currentUserOAuthConnectionCache keys, add a TODO comment explaining the
mismatch and leave a safe conversion or runtime check instead of using "as";
ensure all references to ProviderType and app._currentUserOAuthConnectionCache
are updated accordingly.
- Around line 1454-1477: The hook useConnectedAccount currently calls
useAsyncCache conditionally which violates Rules of Hooks; modify
useConnectedAccount to invoke both useAsyncCache hooks unconditionally — one
against app._currentUserConnectedAccountsCache (with [session] as args) and one
against app._currentUserOAuthConnectionCache (with [session, idOrAccount,
scopeString, options?.or === 'redirect'] or appropriate dummy args when
idOrAccount is an object) — then pick and return the correct result
(object-based match from connectedAccounts if idOrAccount is the object shape,
otherwise the provider-based OAuthConnection); alternatively you can split into
two hooks like useConnectedAccountByProvider and useConnectedAccountByObject to
avoid dummy fetches.
In `@packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts`:
- Around line 442-470: The object-overload `scopes` option is ignored because
_createServerOAuthConnectionFromCrudItem hardcodes the cache key scope element
to "" in getAccessToken and useAccessToken; update
_createServerOAuthConnectionFromCrudItem to accept/receive the requested scopes
(e.g. add a scopes parameter or accept it via the calling path) and include
those scopes when calling
app._serverUserOAuthConnectionAccessTokensByAccountCache.getOrWait and
useAsyncCache (replace the "" element with a deterministic representation of the
scopes array, e.g. joined string or stable key), so getAccessToken and
useAccessToken actually respect the `{ scopes?: string[] }` passed to
getConnectedAccount/getOrLinkConnectedAccount; alternatively, if you prefer to
keep the current cache shape, remove the `{ scopes?: string[] }` option from the
object-based getConnectedAccount/useConnectedAccount overloads to avoid the
mismatch.
- Around line 508-531: The current useConnectedAccount implementation
conditionally calls useAsyncCache in two branches which violates React hooks
ordering; refactor so both useAsyncCache hooks are invoked unconditionally and
then select the appropriate result based on idOrAccount's shape: always compute
scopeString, call useAsyncCache(app._serverUserConnectedAccountsCache, [crud.id]
as const, "user.useConnectedAccount()") to get connectedAccounts and also call
useAsyncCache(app._serverUserOAuthConnectionCache, [crud.id, idOrAccount,
scopeString, options?.or === 'redirect'] as const, "user.useConnectedAccount()")
to get oauthConnection, then if idOrAccount is the object shape (has provider
and providerAccountId) filter connectedAccounts for the matching
provider/providerAccountId and return that (or null), otherwise return the
oauthConnection result; keep all original identifiers (useConnectedAccount,
useAsyncCache, app._serverUserConnectedAccountsCache,
app._serverUserOAuthConnectionCache, crud.id, scopeString, options?.or) so the
hooks are always called in the same order.
🧹 Nitpick comments (8)
examples/demo/src/app/connected-accounts/page.tsx (2)
12-22: Guideline violation: try-catch-all withcatch (e: any).This pattern is repeated in four places (lines 18, 115, 138, 210). The coding guidelines prohibit both try-catch-all and unannoted
any. Consider usingrunAsynchronouslyWithAlertor at least narrowing the caught type (e.g.,catch (e: unknown)with aninstanceofcheck).Proposed fix (example for this occurrence)
- const fetchAccessToken = async () => { - setLoading(true); - setError(null); - try { - const { accessToken } = await account.getAccessToken(); - setAccessToken(accessToken); - } catch (e: any) { - setError(e.message || "Failed to get access token"); - } finally { - setLoading(false); - } - }; + const fetchAccessToken = async () => { + setLoading(true); + setError(null); + try { + const { accessToken } = await account.getAccessToken(); + setAccessToken(accessToken); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to get access token"); + } finally { + setLoading(false); + } + };As per coding guidelines, "Never try-catch-all, never void a promise, and never use .catch(console.error) or similar; use
runAsynchronouslyorrunAsynchronouslyWithAlertinstead for error handling" and "Try to avoid theanytype; whenever you need to useany, leave a comment explaining why you're using it".
105-105: Avoidas anytype cast to bypass the type system.
provider as anysilences the type checker instead of properly handling the mismatch between the free-formstringstate and the expected provider union type. Consider typingprovideras the expected union or validating/narrowing before the call.As per coding guidelines, "Do not use
as/any/type casts or anything else to bypass the type system unless you specifically asked the user about it".apps/e2e/tests/js/connected-accounts.test.ts (1)
391-394: Non-null assertions in test code — consider?? throwErr().Lines 394 and 442 use
serverUser!.listConnectedAccounts(). While the precedingexpect(serverUser).not.toBeNull()makes the intent clear, the coding guideline prefers?? throwErr(...)over non-null assertions. In test code this is low-impact, but for consistency:🔧 Suggested change
- const serverUser = await apps.serverApp.getUser(user.id); - expect(serverUser).not.toBeNull(); - - const connectedAccounts = await serverUser!.listConnectedAccounts(); + const serverUser = await apps.serverApp.getUser(user.id) ?? (() => { throw new Error("Expected serverUser to be defined"); })(); + + const connectedAccounts = await serverUser.listConnectedAccounts();As per coding guidelines, "Code defensively; prefer
?? throwErr(...)over non-null assertions with good error messages that explicitly state the assumption".apps/e2e/tests/backend/endpoints/api/v1/connected-accounts.test.ts (1)
579-579:anytype used without explanatory comment.Multiple occurrences of
(i: any)in.map()/.find()calls (lines 579, 613, 864, 869, 870) lack the required comment. If the response body is untyped, a single comment at the first usage would suffice.As per coding guidelines, "whenever you need to use
any, leave a comment explaining why you're using it".apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/[provider_account_id]/access-token/crud.tsx (3)
38-46: Redundant account existence check — consider removing the first one.The user's
oauth_providersare checked at line 41 (withoutallowConnectedAccountsfilter), and the same account is looked up again via Prisma at line 54 (with the filter). If the first check passes butallowConnectedAccountsis false, the error from line 65 isOAuthConnectionNotConnectedToUser, which is misleading — the connection exists but isn't enabled for connected accounts.The first check could be removed entirely since the Prisma query (lines 54-62) is more authoritative and covers both existence and the
allowConnectedAccountsconstraint. This would also save anadminReadcall.
85-99: Sequential provider-side validity checks could be slow.Each cached access token is checked against the provider one-by-one (line 87). If a user has multiple invalidated tokens, this results in sequential HTTP calls to the provider. For most real-world cases there will be 0–1 tokens, so this is acceptable, but worth noting if performance becomes a concern with many stale tokens.
16-190: Extract shared token retrieval and refresh logic into a common utility.This handler duplicates nearly all of the token retrieval and refresh logic from the legacy handler (
apps/backend/src/app/api/latest/connected-accounts/[user_id]/[provider_id]/access-token/crud.tsx). The two handlers share:
- Provider validation and initialization
- Access token validity checking and filtering
- Refresh token retrieval and processing
- Error handling and logging during token refresh
The main difference is how they query for tokens: the legacy handler uses nested relational queries with
projectUserOAuthAccount, while this handler pre-fetches theoauthAccountand uses directoauthAccountIdlookups. Consider extracting the core token refresh logic into a shared utility that accepts the OAuth account as a parameter, then update both handlers to use it. This will ensure consistency and reduce maintenance burden.packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts (1)
391-394: Prefer?? throwErr(...)over non-null assertion and silent fallback.
user!on line 393 and?? ""on line 394 are both fragile. Although the precedinghasConnectionguard guarantees both values exist, the coding guidelines prefer explicit assertion errors over!and silent fallbacks.🛡️ Proposed defensive fix
- const matchingProvider = user!.oauth_providers.find((p) => p.id === options.providerId); - const providerAccountId = matchingProvider?.account_id ?? ""; + const nonNullUser = user ?? throwErr("Expected user to be non-null when hasConnection is true"); + const matchingProvider = nonNullUser.oauth_providers.find((p) => p.id === options.providerId) + ?? throwErr(`Expected to find oauth provider ${options.providerId} on user`); + const providerAccountId = matchingProvider.account_id ?? throwErr(`Expected account_id on oauth provider ${options.providerId}`);As per coding guidelines, "Code defensively; prefer
?? throwErr(...)over non-null assertions with good error messages that explicitly state the assumption."
...atest/connected-accounts/[user_id]/[provider_id]/[provider_account_id]/access-token/crud.tsx
Show resolved
Hide resolved
apps/e2e/tests/backend/endpoints/api/v1/connected-accounts.test.ts
Outdated
Show resolved
Hide resolved
apps/e2e/tests/backend/endpoints/api/v1/connected-accounts.test.ts
Outdated
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
Outdated
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
Outdated
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
Show resolved
Hide resolved
packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
Show resolved
Hide resolved
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
| `Failed to retrieve an OAuth access token for the connected account (provider: ${provider}). ${details}`, | ||
| ] as const, | ||
| (json: any) => [json.provider, json.details] as [string, string], | ||
| ); |
There was a problem hiding this comment.
Missing details object in KnownError constructor breaks deserialization
Medium Severity
The OAuthAccessTokenNotAvailable error constructor's create function returns [400, message] without a details object as the third element. The constructorArgsFromJson function expects json.provider and json.details from the details object, but since no details are serialized, json will be undefined when KnownError.fromJson is called, causing a TypeError. Every other parameterized error constructor in this file (e.g., SchemaError, InvalidPublishableClientKey, TeamInvitationRestrictedUserNotAllowed) includes a { provider, details } style object as the third tuple element.
|
|
||
| throw new KnownErrors.OAuthConnectionDoesNotHaveRequiredScope(); | ||
| }, | ||
| })); |
There was a problem hiding this comment.
Near-identical endpoint files create maintenance risk
Low Severity
The new connectedAccountAccessTokenByAccountCrudHandlers file is ~190 lines that nearly duplicate the existing connectedAccountAccessTokenCrudHandlers file (~176 lines). The core logic for finding valid access tokens, checking validity, refreshing tokens, and storing new tokens is copy-pasted. The only differences are the Prisma query filter (direct oauthAccountId vs projectUserOAuthAccount relation) and the allowConnectedAccounts constraint. Extracting the shared token retrieval/refresh logic into a common helper would reduce the risk of one endpoint being updated while the other is missed.
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Fix all issues with AI agents
In `@apps/e2e/tests/backend/endpoints/api/v1/connected-accounts.test.ts`:
- Around line 503-507: The test title string currently reads "should return 404
when trying to get access token for non-existent provider_account_id" but the
assertion/snapshot expects a 400 with OAUTH_CONNECTION_NOT_CONNECTED_TO_USER;
update the test description in the it(...) call to reflect the actual expected
status and error (for example: "should return 400 when getting access token for
non-existent provider_account_id (OAUTH_CONNECTION_NOT_CONNECTED_TO_USER)") so
the title matches the assertion in the test handling code.
In `@examples/demo/src/app/connected-accounts/page.tsx`:
- Around line 101-214: Replace the broad try/catch + any casts in
handleGetByProvider, handleGetByBothIds and handleLink with
runAsynchronouslyWithAlert: validate that provider is a non-empty string (reject
or show alert if not) instead of using provider as any, remove the `: any` error
casts, and wrap the async calls (user.getConnectedAccount(...) and
user.linkConnectedAccount(...)) inside runAsynchronouslyWithAlert to surface
errors; ensure providerAccountId handling remains the same for getByBothIds and
that scopes are parsed the same in handleLink before passing to
user.linkConnectedAccount.
In `@packages/stack-shared/src/known-errors.tsx`:
- Around line 1130-1138: The OAuthAccessTokenNotAvailable constructor currently
doesn't persist the details field so deserialization (constructorArgsFromJson)
loses provider/details and can throw; update the createKnownErrorConstructor
call for OAuthAccessTokenNotAvailable so the constructor accepts and stores a
details string (include it in the error payload/state returned by the factory)
and make the JSON-to-args function resilient by guarding missing
json.provider/json.details (e.g., default to empty string) so round-tripping via
constructorArgsFromJson works without throwing.
In `@packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts`:
- Around line 394-397: Replace the non-null assertion on user in the block that
finds the OAuth provider: instead of user!.oauth_providers, assert user exists
with a defensive check (e.g., user ?? throwErr("Expected user to be present when
hasConnection is true in client-app-impl")) and likewise ensure the found
matchingProvider is present — do not default providerAccountId to an empty
string; if matchingProvider is missing throw an explicit error (e.g., throwErr
or new Error) that includes options.providerId and the context (function/class:
client-app-impl handling connections) so broken assumptions are surfaced
clearly.
| it("should return 404 when trying to get access token for non-existent provider_account_id", async ({ expect }) => { | ||
| await Auth.OAuth.signIn(); | ||
|
|
||
| const response = await niceBackendFetch("/api/v1/connected-accounts/me/spotify/non-existent-account-id/access-token", { | ||
| accessType: "client", |
There was a problem hiding this comment.
Test title says 404 but assertion expects 400
The snapshot shows a 400 with OAUTH_CONNECTION_NOT_CONNECTED_TO_USER. Please update the description.
✏️ Suggested fix
-it("should return 404 when trying to get access token for non-existent provider_account_id", async ({ expect }) => {
+it("should return 400 when trying to get access token for non-existent provider_account_id", async ({ expect }) => {🤖 Prompt for AI Agents
In `@apps/e2e/tests/backend/endpoints/api/v1/connected-accounts.test.ts` around
lines 503 - 507, The test title string currently reads "should return 404 when
trying to get access token for non-existent provider_account_id" but the
assertion/snapshot expects a 400 with OAUTH_CONNECTION_NOT_CONNECTED_TO_USER;
update the test description in the it(...) call to reflect the actual expected
status and error (for example: "should return 400 when getting access token for
non-existent provider_account_id (OAUTH_CONNECTION_NOT_CONNECTED_TO_USER)") so
the title matches the assertion in the test handling code.
| const handleGetByProvider = async () => { | ||
| setLoading(true); | ||
| try { | ||
| const account = await user.getConnectedAccount(provider as any); | ||
| if (account) { | ||
| setResult(JSON.stringify({ | ||
| provider: account.provider, | ||
| providerAccountId: account.providerAccountId, | ||
| id: account.id, | ||
| }, null, 2)); | ||
| } else { | ||
| setResult("null (no account found)"); | ||
| } | ||
| } catch (e: any) { | ||
| setResult(`Error: ${e.message}`); | ||
| } finally { | ||
| setLoading(false); | ||
| } | ||
| }; | ||
|
|
||
| const handleGetByBothIds = async () => { | ||
| setLoading(true); | ||
| try { | ||
| const account = await user.getConnectedAccount({ | ||
| provider, | ||
| providerAccountId, | ||
| }); | ||
| if (account) { | ||
| setResult(JSON.stringify({ | ||
| provider: account.provider, | ||
| providerAccountId: account.providerAccountId, | ||
| id: account.id, | ||
| }, null, 2)); | ||
| } else { | ||
| setResult("null (no account found)"); | ||
| } | ||
| } catch (e: any) { | ||
| setResult(`Error: ${e.message}`); | ||
| } finally { | ||
| setLoading(false); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <Card> | ||
| <CardHeader> | ||
| <CardTitle>Get Connected Account</CardTitle> | ||
| <CardDescription> | ||
| Look up a specific connected account by provider or by both provider and account ID | ||
| </CardDescription> | ||
| </CardHeader> | ||
| <CardContent className="space-y-4"> | ||
| <div className="space-y-2"> | ||
| <label className="text-sm font-medium">Provider</label> | ||
| <input | ||
| type="text" | ||
| value={provider} | ||
| onChange={(e) => setProvider(e.target.value)} | ||
| className="w-full px-3 py-2 border rounded-md dark:bg-gray-800 dark:border-gray-700" | ||
| placeholder="e.g., spotify, google, github" | ||
| /> | ||
| </div> | ||
|
|
||
| <div className="space-y-2"> | ||
| <label className="text-sm font-medium">Provider Account ID (optional)</label> | ||
| <input | ||
| type="text" | ||
| value={providerAccountId} | ||
| onChange={(e) => setProviderAccountId(e.target.value)} | ||
| className="w-full px-3 py-2 border rounded-md dark:bg-gray-800 dark:border-gray-700" | ||
| placeholder="e.g., user123, email@example.com" | ||
| /> | ||
| </div> | ||
|
|
||
| <div className="flex gap-2"> | ||
| <Button onClick={handleGetByProvider} loading={loading} variant="secondary"> | ||
| Get by Provider Only | ||
| </Button> | ||
| <Button onClick={handleGetByBothIds} loading={loading} disabled={!providerAccountId}> | ||
| Get by Provider + Account ID | ||
| </Button> | ||
| </div> | ||
|
|
||
| {result && ( | ||
| <div className="mt-4"> | ||
| <Typography variant="secondary" type="label">Result:</Typography> | ||
| <pre className="text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded overflow-x-auto mt-1"> | ||
| {result} | ||
| </pre> | ||
| </div> | ||
| )} | ||
| </CardContent> | ||
| </Card> | ||
| ); | ||
| } | ||
|
|
||
| function LinkNewAccountDemo() { | ||
| const user = useUser({ or: 'redirect' }); | ||
| const [provider, setProvider] = useState("spotify"); | ||
| const [scopes, setScopes] = useState(""); | ||
| const [loading, setLoading] = useState(false); | ||
|
|
||
| const handleLink = async () => { | ||
| setLoading(true); | ||
| try { | ||
| await user.linkConnectedAccount(provider, { | ||
| scopes: scopes ? scopes.split(",").map(s => s.trim()) : undefined, | ||
| }); | ||
| } catch (e: any) { | ||
| alert(`Error: ${e.message}`); | ||
| } finally { | ||
| setLoading(false); | ||
| } | ||
| }; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Check if the file exists and examine the relevant lines
fd -t f "connected-accounts/page.tsx" | head -5Repository: stack-auth/stack-auth
Length of output: 47
🏁 Script executed:
# Check the actual content around the specified lines
if [ -f "examples/demo/src/app/connected-accounts/page.tsx" ]; then
cat -n "examples/demo/src/app/connected-accounts/page.tsx" | sed -n '95,220p'
fiRepository: stack-auth/stack-auth
Length of output: 4847
🏁 Script executed:
# Search for runAsynchronouslyWithAlert in the codebase to verify the correct import path
rg "runAsynchronouslyWithAlert" --type ts --type tsx -B 2 -A 2 | head -40Repository: stack-auth/stack-auth
Length of output: 92
🏁 Script executed:
# Check for Button component usage with async handlers
rg "Button.*onClick.*async|onClick=.*async" --type tsx -B 1 -A 1 | head -30Repository: stack-auth/stack-auth
Length of output: 92
🏁 Script executed:
# Look for provider type definitions in the codebase
rg "ProviderType|type.*Provider" --type ts --type tsx packages/stack-shared | grep -v node_modules | head -20Repository: stack-auth/stack-auth
Length of output: 92
🏁 Script executed:
# Search for runAsynchronouslyWithAlert without file type restriction
rg "runAsynchronouslyWithAlert" -B 2 -A 2 | head -50Repository: stack-auth/stack-auth
Length of output: 4178
🏁 Script executed:
# Check the stack-shared package structure for promises utilities
find packages/stack-shared -name "*promises*" -o -name "*utils*" | grep -E "\.(ts|tsx|js|jsx)$" | head -20Repository: stack-auth/stack-auth
Length of output: 109
🏁 Script executed:
# Look for provider type definitions
rg "export.*type.*Provider" packages/stack-shared | head -15Repository: stack-auth/stack-auth
Length of output: 768
🏁 Script executed:
# Check Button component signature for onClick handler support
rg "interface.*Button.*Props|type.*Button.*Props" packages/stack-ui -A 10 | head -30Repository: stack-auth/stack-auth
Length of output: 2509
🏁 Script executed:
# Look at an example of runAsynchronouslyWithAlert usage in the codebase
rg -l "runAsynchronouslyWithAlert" | head -3 | xargs -I {} sh -c 'echo "=== {} ===" && head -50 {}'Repository: stack-auth/stack-auth
Length of output: 4620
🏁 Script executed:
# Check the runAsynchronously function signature to understand the return type
sed -n '1,100p' packages/stack-shared/src/utils/promises.tsx | head -60Repository: stack-auth/stack-auth
Length of output: 2117
🏁 Script executed:
# Look for existing provider type usage/validation patterns in connected-accounts or similar files
rg "allProviders|standardProviders" packages/stack-shared/src/utils/oauth.tsx -B 2 -A 5 | head -30Repository: stack-auth/stack-auth
Length of output: 627
🏁 Script executed:
# Check the exact import paths that should be used
cat packages/stack-shared/src/utils/oauth.tsx | head -30Repository: stack-auth/stack-auth
Length of output: 627
🏁 Script executed:
# Verify if allProviders/standardProviders can be imported from the dist version
fd -type f "oauth" packages/stack-shared/dist | head -5Repository: stack-auth/stack-auth
Length of output: 235
🏁 Script executed:
# Check how provider validation is done in other parts of the codebase
rg "isProviderType|allProviders|standardProviders" examples/ -B 2 -A 2 | head -40Repository: stack-auth/stack-auth
Length of output: 47
🏁 Script executed:
# Check how runAsynchronously and runAsynchronouslyWithAlert handle the promise/function signature
sed -n '50,150p' packages/stack-shared/src/utils/promises.tsxRepository: stack-auth/stack-auth
Length of output: 3822
🏁 Script executed:
# Look at the actual implementation of runAsynchronously to understand its parameters
rg "export function runAsynchronously" packages/stack-shared/src/utils/promises.tsx -A 15Repository: stack-auth/stack-auth
Length of output: 1300
Replace try-catch-all + any cast in async handlers
These handlers use try-catch with any type casts and provider as any. Switch to runAsynchronouslyWithAlert for proper error handling and validate provider type instead of casting. Apply the same pattern to handleGetByBothIds and handleLink.
🔧 Suggested fix
-import { OAuthConnection, useUser } from "@stackframe/stack";
+import { OAuthConnection, useUser } from "@stackframe/stack";
+import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
+import { allProviders } from "@stackframe/stack-shared/dist/utils/oauth";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Typography } from "@stackframe/stack-ui";
import { useState } from "react";
+const isProviderType = (value: string): value is typeof allProviders[number] =>
+ allProviders.includes(value as typeof allProviders[number]);
+
function GetConnectedAccountDemo() {
const user = useUser({ or: 'redirect' });
const [provider, setProvider] = useState("spotify");
const [providerAccountId, setProviderAccountId] = useState("");
const [result, setResult] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
- const handleGetByProvider = async () => {
- setLoading(true);
- try {
- const account = await user.getConnectedAccount(provider as any);
- if (account) {
- setResult(JSON.stringify({
- provider: account.provider,
- providerAccountId: account.providerAccountId,
- id: account.id,
- }, null, 2));
- } else {
- setResult("null (no account found)");
- }
- } catch (e: any) {
- setResult(`Error: ${e.message}`);
- } finally {
- setLoading(false);
- }
- };
+ const handleGetByProvider = () => runAsynchronouslyWithAlert(async () => {
+ setLoading(true);
+ try {
+ if (!isProviderType(provider)) throw new Error(`Unknown provider: ${provider}`);
+ const account = await user.getConnectedAccount(provider);
+ if (account) {
+ setResult(JSON.stringify({
+ provider: account.provider,
+ providerAccountId: account.providerAccountId,
+ id: account.id,
+ }, null, 2));
+ } else {
+ setResult("null (no account found)");
+ }
+ } finally {
+ setLoading(false);
+ }
+ });
const handleGetByBothIds = async () => {
setLoading(true);
try {
const account = await user.getConnectedAccount({
provider,
providerAccountId,
});
if (account) {
setResult(JSON.stringify({
provider: account.provider,
providerAccountId: account.providerAccountId,
id: account.id,
}, null, 2));
} else {
setResult("null (no account found)");
}
- } catch (e: any) {
- setResult(`Error: ${e.message}`);
- } finally {
+ } finally {
setLoading(false);
}
};
+
+ const handleGetByBothIds = () => runAsynchronouslyWithAlert(async () => {
+ setLoading(true);
+ try {
+ const account = await user.getConnectedAccount({
+ provider,
+ providerAccountId,
+ });
+ if (account) {
+ setResult(JSON.stringify({
+ provider: account.provider,
+ providerAccountId: account.providerAccountId,
+ id: account.id,
+ }, null, 2));
+ } else {
+ setResult("null (no account found)");
+ }
+ } finally {
+ setLoading(false);
+ }
+ });
function LinkNewAccountDemo() {
const user = useUser({ or: 'redirect' });
const [provider, setProvider] = useState("spotify");
const [scopes, setScopes] = useState("");
const [loading, setLoading] = useState(false);
- const handleLink = async () => {
- setLoading(true);
- try {
- await user.linkConnectedAccount(provider, {
- scopes: scopes ? scopes.split(",").map(s => s.trim()) : undefined,
- });
- } catch (e: any) {
- alert(`Error: ${e.message}`);
- } finally {
- setLoading(false);
- }
- };
+ const handleLink = () => runAsynchronouslyWithAlert(async () => {
+ setLoading(true);
+ try {
+ await user.linkConnectedAccount(provider, {
+ scopes: scopes ? scopes.split(",").map((s) => s.trim()) : undefined,
+ });
+ } finally {
+ setLoading(false);
+ }
+ });🤖 Prompt for AI Agents
In `@examples/demo/src/app/connected-accounts/page.tsx` around lines 101 - 214,
Replace the broad try/catch + any casts in handleGetByProvider,
handleGetByBothIds and handleLink with runAsynchronouslyWithAlert: validate that
provider is a non-empty string (reject or show alert if not) instead of using
provider as any, remove the `: any` error casts, and wrap the async calls
(user.getConnectedAccount(...) and user.linkConnectedAccount(...)) inside
runAsynchronouslyWithAlert to surface errors; ensure providerAccountId handling
remains the same for getByBothIds and that scopes are parsed the same in
handleLink before passing to user.linkConnectedAccount.
| const OAuthAccessTokenNotAvailable = createKnownErrorConstructor( | ||
| KnownError, | ||
| "OAUTH_ACCESS_TOKEN_NOT_AVAILABLE", | ||
| (provider: string, details: string) => [ | ||
| 400, | ||
| `Failed to retrieve an OAuth access token for the connected account (provider: ${provider}). ${details}`, | ||
| ] as const, | ||
| (json: any) => [json.provider, json.details] as [string, string], | ||
| ); |
There was a problem hiding this comment.
Include details payload so OAuthAccessTokenNotAvailable round-trips
constructorArgsFromJson is fed json.details, but the constructor never sets details, so deserialization loses provider/details (and can throw). Add a details object and guard missing fields.
🔧 Suggested fix
const OAuthAccessTokenNotAvailable = createKnownErrorConstructor(
KnownError,
"OAUTH_ACCESS_TOKEN_NOT_AVAILABLE",
(provider: string, details: string) => [
400,
`Failed to retrieve an OAuth access token for the connected account (provider: ${provider}). ${details}`,
+ {
+ provider,
+ details,
+ },
] as const,
- (json: any) => [json.provider, json.details] as [string, string],
+ (json: { provider?: string, details?: string }) => [
+ json.provider ?? throwErr("provider not found in OAuthAccessTokenNotAvailable details"),
+ json.details ?? throwErr("details not found in OAuthAccessTokenNotAvailable details"),
+ ] as const,
);🤖 Prompt for AI Agents
In `@packages/stack-shared/src/known-errors.tsx` around lines 1130 - 1138, The
OAuthAccessTokenNotAvailable constructor currently doesn't persist the details
field so deserialization (constructorArgsFromJson) loses provider/details and
can throw; update the createKnownErrorConstructor call for
OAuthAccessTokenNotAvailable so the constructor accepts and stores a details
string (include it in the error payload/state returned by the factory) and make
the JSON-to-args function resilient by guarding missing
json.provider/json.details (e.g., default to empty string) so round-tripping via
constructorArgsFromJson works without throwing.
| // Find the matching oauth provider to get the providerAccountId | ||
| // At this point, user is guaranteed to be non-null because we returned early if !hasConnection | ||
| const matchingProvider = user!.oauth_providers.find((p) => p.id === options.providerId); | ||
| const providerAccountId = matchingProvider?.account_id ?? ""; |
There was a problem hiding this comment.
Replace user! non-null assertion with a defensive check.
Line 396 uses user!.oauth_providers with a comment that the user is "guaranteed to be non-null." Per coding guidelines, prefer ?? throwErr(...) over non-null assertions to surface broken assumptions clearly.
Also, matchingProvider?.account_id ?? "" on line 397 silently falls back to an empty string if the provider isn't found — this could mask bugs where the provider list is inconsistent with hasConnection.
Proposed fix
- const matchingProvider = user!.oauth_providers.find((p) => p.id === options.providerId);
- const providerAccountId = matchingProvider?.account_id ?? "";
+ const matchingProvider = (user ?? throwErr("Expected user to be non-null because hasConnection was true")).oauth_providers.find((p) => p.id === options.providerId);
+ const providerAccountId = matchingProvider?.account_id ?? throwErr(`Expected to find provider ${options.providerId} in user's oauth_providers list`);As per coding guidelines, "Prefer ?? throwErr(...) over non-null assertions with good error messages explicitly stating the assumption that must've been violated."
🤖 Prompt for AI Agents
In `@packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts`
around lines 394 - 397, Replace the non-null assertion on user in the block that
finds the OAuth provider: instead of user!.oauth_providers, assert user exists
with a defensive check (e.g., user ?? throwErr("Expected user to be present when
hasConnection is true in client-app-impl")) and likewise ensure the found
matchingProvider is present — do not default providerAccountId to an empty
string; if matchingProvider is missing throw an explicit error (e.g., throwErr
or new Error) that includes options.providerId and the context (function/class:
client-app-impl handling connections) so broken assumptions are surfaced
clearly.


Note
Medium Risk
Introduces new OAuth-token retrieval paths and refresh logic keyed by
provider_account_id, touching authentication/authorization and token storage; changes are additive and heavily tested but failures could impact access-token issuance or expose tokens if auth checks regress.Overview
Adds new connected-accounts capabilities that support multiple OAuth accounts per provider: a
GET /connected-accounts/:user_idlisting endpoint (client restricted to self; server can query any user) and a newPOST /connected-accounts/:user_id/:provider_id/:provider_account_id/access-tokenendpoint that fetches cached access tokens or refreshes via stored refresh tokens with scope filtering.Extends the SDK (client + server) with
listConnectedAccounts, object-basedgetConnectedAccount({ provider, providerAccountId }), and per-account access token retrieval; the legacy provider-only APIs remain but are now explicitly deprecated and the newOAuthConnection.getAccessTokenreturns aResultwith a newOAUTH_ACCESS_TOKEN_NOT_AVAILABLEknown error instead of throwing.Includes a demo page showcasing the new APIs and adds substantial E2E coverage for listing, per-account token retrieval, authorization rules, and edge cases; also tweaks the dev launchpad links to incorporate the
STACK_PORT_PREFIXsubdomain pattern.Written by Cursor Bugbot for commit 48ace41. This will update automatically on new commits. Configure here.
Summary by CodeRabbit
New Features
Tests
Other