-
Notifications
You must be signed in to change notification settings - Fork 516
More connected accounts #1165
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
More connected accounts #1165
Changes from 5 commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
28f83b9
More connected accounts
N2D4 5c6202d
Merge branch 'dev' into more-connected-accounts
N2D4 3997f83
fixes
N2D4 5b2acec
Merge branch 'dev' into more-connected-accounts
N2D4 48ace41
Fixes
N2D4 03274c0
Merge remote-tracking branch 'origin/dev' into more-connected-accounts
N2D4 ec24cec
PR fixes
N2D4 949744c
Update pr-comments-review.md
N2D4 ef9585d
Fixes
N2D4 ff0c153
Fixes
N2D4 6efa56b
Merge branch 'dev' into more-connected-accounts
N2D4 ff8f876
Tests
N2D4 08bf072
Merge branch 'dev' into more-connected-accounts
N2D4 ae80da0
Fix consistency
N2D4 5b49c6f
Fix
N2D4 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
190 changes: 190 additions & 0 deletions
190
...st/connected-accounts/[user_id]/[provider_id]/[provider_account_id]/access-token/crud.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| import { usersCrudHandlers } from "@/app/api/latest/users/crud"; | ||
| import { getProvider } from "@/oauth"; | ||
| import { TokenSet } from "@/oauth/providers/base"; | ||
| import { getPrismaClientForTenancy } from "@/prisma-client"; | ||
| import { createCrudHandlers } from "@/route-handlers/crud-handler"; | ||
| import { KnownErrors } from "@stackframe/stack-shared"; | ||
| import { connectedAccountAccessTokenCrud } from "@stackframe/stack-shared/dist/interface/crud/connected-accounts"; | ||
| import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; | ||
| import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; | ||
| import { StackAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; | ||
| import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; | ||
| import { Result } from "@stackframe/stack-shared/dist/utils/results"; | ||
| import { extractScopes } from "@stackframe/stack-shared/dist/utils/strings"; | ||
|
|
||
|
|
||
| export const connectedAccountAccessTokenByAccountCrudHandlers = createLazyProxy(() => createCrudHandlers(connectedAccountAccessTokenCrud, { | ||
| paramsSchema: yupObject({ | ||
| provider_id: yupString().defined(), | ||
| provider_account_id: yupString().defined(), | ||
| user_id: userIdOrMeSchema.defined(), | ||
| }), | ||
| async onCreate({ auth, data, params }) { | ||
| if (auth.type === 'client' && auth.user?.id !== params.user_id) { | ||
| throw new StatusError(StatusError.Forbidden, "Client can only access its own connected accounts"); | ||
| } | ||
|
|
||
| const providerRaw = Object.entries(auth.tenancy.config.auth.oauth.providers).find(([providerId, _]) => providerId === params.provider_id); | ||
| if (!providerRaw) { | ||
| throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); | ||
| } | ||
|
|
||
| const provider = { id: providerRaw[0], ...providerRaw[1] }; | ||
|
|
||
| if (provider.isShared && !getNodeEnvironment().includes('prod') && getEnvVariable('STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS', '') !== 'true') { | ||
| throw new KnownErrors.OAuthAccessTokenNotAvailableWithSharedOAuthKeys(); | ||
| } | ||
|
|
||
| const user = await usersCrudHandlers.adminRead({ tenancy: auth.tenancy, user_id: params.user_id }); | ||
|
|
||
| // Find the specific OAuth provider by both provider_id and account_id | ||
| const matchingProvider = user.oauth_providers.find( | ||
| p => p.id === params.provider_id && p.account_id === params.provider_account_id | ||
| ); | ||
| if (!matchingProvider) { | ||
| throw new KnownErrors.OAuthConnectionNotConnectedToUser(); | ||
| } | ||
|
|
||
| const providerInstance = await getProvider(provider); | ||
|
|
||
| // ====================== retrieve access token if it exists ====================== | ||
| const prisma = await getPrismaClientForTenancy(auth.tenancy); | ||
|
|
||
| // Find the specific oauth account by provider_id and provider_account_id | ||
| const oauthAccount = await prisma.projectUserOAuthAccount.findFirst({ | ||
| where: { | ||
| tenancyId: auth.tenancy.id, | ||
| projectUserId: params.user_id, | ||
| configOAuthProviderId: params.provider_id, | ||
| providerAccountId: params.provider_account_id, | ||
| allowConnectedAccounts: true, | ||
| }, | ||
| }); | ||
|
|
||
| if (!oauthAccount) { | ||
| throw new KnownErrors.OAuthConnectionNotConnectedToUser(); | ||
| } | ||
|
|
||
| const accessTokens = await prisma.oAuthAccessToken.findMany({ | ||
| where: { | ||
| tenancyId: auth.tenancy.id, | ||
| oauthAccountId: oauthAccount.id, | ||
| expiresAt: { | ||
| // is at least 5 minutes in the future | ||
| gt: new Date(Date.now() + 5 * 60 * 1000), | ||
| }, | ||
| isValid: true, | ||
| }, | ||
| include: { | ||
| projectUserOAuthAccount: true, | ||
| }, | ||
| }); | ||
| const filteredTokens = accessTokens.filter((t) => { | ||
| return extractScopes(data.scope || "").every((scope) => t.scopes.includes(scope)); | ||
| }); | ||
| for (const token of filteredTokens) { | ||
| // some providers (particularly GitHub) invalidate access tokens on the server-side, in which case we want to request a new access token | ||
| if (await providerInstance.checkAccessTokenValidity(token.accessToken)) { | ||
| return { access_token: token.accessToken }; | ||
| } else { | ||
| // mark the token as invalid | ||
| await prisma.oAuthAccessToken.update({ | ||
| where: { | ||
| id: token.id, | ||
| }, | ||
| data: { | ||
| isValid: false, | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| // ============== no valid access token found, try to refresh the token ============== | ||
|
|
||
| const refreshTokens = await prisma.oAuthToken.findMany({ | ||
| where: { | ||
| tenancyId: auth.tenancy.id, | ||
| oauthAccountId: oauthAccount.id, | ||
| isValid: true, | ||
| }, | ||
| include: { | ||
| projectUserOAuthAccount: true, | ||
| }, | ||
| }); | ||
|
|
||
| const filteredRefreshTokens = refreshTokens.filter((t) => { | ||
| return extractScopes(data.scope || "").every((scope) => t.scopes.includes(scope)); | ||
| }); | ||
|
|
||
| if (filteredRefreshTokens.length === 0) { | ||
| throw new KnownErrors.OAuthConnectionDoesNotHaveRequiredScope(); | ||
| } | ||
|
|
||
| for (const token of filteredRefreshTokens) { | ||
| let tokenSetResult: Result<TokenSet, string>; | ||
| try { | ||
| tokenSetResult = await providerInstance.getAccessToken({ | ||
| refreshToken: token.refreshToken, | ||
| scope: data.scope, | ||
| }); | ||
| } catch (error) { | ||
| // Unexpected errors (not handled by the provider) are logged and we continue to the next token | ||
| captureError('oauth-access-token-refresh-unexpected-error', new StackAssertionError('Unexpected error refreshing access token — this may indicate a bug or misconfiguration', { | ||
| error, | ||
| tenancyId: auth.tenancy.id, | ||
| providerId: params.provider_id, | ||
| providerAccountId: params.provider_account_id, | ||
| userId: params.user_id, | ||
| scope: data.scope, | ||
| })); | ||
|
|
||
| tokenSetResult = Result.error("Unexpected error refreshing access token"); | ||
| } | ||
|
|
||
| if (tokenSetResult.status === "error") { | ||
| await prisma.oAuthToken.update({ | ||
| where: { id: token.id }, | ||
| data: { isValid: false }, | ||
| }); | ||
|
|
||
| continue; | ||
| } | ||
|
|
||
| const tokenSet = tokenSetResult.data; | ||
| if (tokenSet.accessToken) { | ||
| await prisma.oAuthAccessToken.create({ | ||
| data: { | ||
| tenancyId: auth.tenancy.id, | ||
| accessToken: tokenSet.accessToken, | ||
| oauthAccountId: oauthAccount.id, | ||
| scopes: token.scopes, | ||
| expiresAt: tokenSet.accessTokenExpiredAt | ||
| } | ||
| }); | ||
|
|
||
| if (tokenSet.refreshToken) { | ||
| // mark the old token as invalid, add the new token to the DB | ||
| const oldToken = token; | ||
| await prisma.oAuthToken.update({ | ||
| where: { id: oldToken.id }, | ||
| data: { isValid: false }, | ||
| }); | ||
| await prisma.oAuthToken.create({ | ||
| data: { | ||
| tenancyId: auth.tenancy.id, | ||
| refreshToken: tokenSet.refreshToken, | ||
| oauthAccountId: oauthAccount.id, | ||
| scopes: oldToken.scopes, | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| return { access_token: tokenSet.accessToken }; | ||
| } else { | ||
| throw new StackAssertionError("No access token returned"); | ||
| } | ||
| } | ||
|
|
||
| throw new KnownErrors.OAuthConnectionDoesNotHaveRequiredScope(); | ||
| }, | ||
|
N2D4 marked this conversation as resolved.
|
||
| })); | ||
|
N2D4 marked this conversation as resolved.
|
||
3 changes: 3 additions & 0 deletions
3
...t/connected-accounts/[user_id]/[provider_id]/[provider_account_id]/access-token/route.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| import { connectedAccountAccessTokenByAccountCrudHandlers } from "./crud"; | ||
|
|
||
| export const POST = connectedAccountAccessTokenByAccountCrudHandlers.createHandler; | ||
|
N2D4 marked this conversation as resolved.
|
||
50 changes: 50 additions & 0 deletions
50
apps/backend/src/app/api/latest/connected-accounts/[user_id]/crud.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import { ensureUserExists } from "@/lib/request-checks"; | ||
| import { getPrismaClientForTenancy } from "@/prisma-client"; | ||
| import { createCrudHandlers } from "@/route-handlers/crud-handler"; | ||
| import { KnownErrors } from "@stackframe/stack-shared"; | ||
| import { connectedAccountCrud } from "@stackframe/stack-shared/dist/interface/crud/connected-accounts"; | ||
| import { userIdOrMeSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields"; | ||
| import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; | ||
| import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; | ||
|
|
||
| export const connectedAccountCrudHandlers = createLazyProxy(() => createCrudHandlers(connectedAccountCrud, { | ||
| paramsSchema: yupObject({ | ||
| user_id: userIdOrMeSchema.defined(), | ||
| }), | ||
| async onList({ auth, params }) { | ||
| const userId = params.user_id; | ||
|
|
||
| if (auth.type === 'client') { | ||
| const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); | ||
| if (!userId || currentUserId !== userId) { | ||
| throw new StatusError(StatusError.Forbidden, 'Client can only list connected accounts for their own user.'); | ||
|
N2D4 marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| const prismaClient = await getPrismaClientForTenancy(auth.tenancy); | ||
|
|
||
| if (userId) { | ||
| await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId }); | ||
| } | ||
|
|
||
| const oauthAccounts = await prismaClient.projectUserOAuthAccount.findMany({ | ||
| where: { | ||
| tenancyId: auth.tenancy.id, | ||
| projectUserId: userId, | ||
| allowConnectedAccounts: true, | ||
| }, | ||
| orderBy: { | ||
| createdAt: 'asc', | ||
| }, | ||
| }); | ||
|
|
||
| return { | ||
| items: oauthAccounts.map((oauthAccount) => ({ | ||
| user_id: oauthAccount.projectUserId ?? throwErr("OAuth account has no project user ID"), | ||
| provider: oauthAccount.configOAuthProviderId, | ||
| provider_account_id: oauthAccount.providerAccountId, | ||
| })), | ||
| is_paginated: false, | ||
| }; | ||
| }, | ||
| })); | ||
3 changes: 3 additions & 0 deletions
3
apps/backend/src/app/api/latest/connected-accounts/[user_id]/route.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| import { connectedAccountCrudHandlers } from "./crud"; | ||
|
|
||
| export const GET = connectedAccountCrudHandlers.listHandler; | ||
|
N2D4 marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.