Skip to content

Commit 9b5a188

Browse files
authored
More connected accounts (#1165)
1 parent ebb394d commit 9b5a188

21 files changed

Lines changed: 2700 additions & 203 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Please review the PR comments with `gh pr status` and fix & resolve those issues that are valid and relevant. Leave those comments that are mostly bullshit unresolved. Report the result to me in detail. Do NOT automatically commit or stage the changes back to the PR!
1+
Please review the PR comments with the `gh` CLI and fix those issues that are valid and relevant. Resolve the comments when you fix them. Also resolve all those comments that no longer exist or have already been resolved. Leave those comments that are mostly bullshit unresolved. Report the result to me in detail. Do NOT automatically commit or stage the changes back to the PR!
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { usersCrudHandlers } from "@/app/api/latest/users/crud";
2+
import { getProvider } from "@/oauth";
3+
import { getPrismaClientForTenancy } from "@/prisma-client";
4+
import { createCrudHandlers } from "@/route-handlers/crud-handler";
5+
import { KnownErrors } from "@stackframe/stack-shared";
6+
import { connectedAccountAccessTokenCrud } from "@stackframe/stack-shared/dist/interface/crud/connected-accounts";
7+
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
8+
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
9+
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
10+
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
11+
import { retrieveOrRefreshAccessToken } from "../../../../access-token-helpers";
12+
13+
14+
export const connectedAccountAccessTokenByAccountCrudHandlers = createLazyProxy(() => createCrudHandlers(connectedAccountAccessTokenCrud, {
15+
paramsSchema: yupObject({
16+
provider_id: yupString().defined(),
17+
provider_account_id: yupString().defined(),
18+
user_id: userIdOrMeSchema.defined(),
19+
}),
20+
async onCreate({ auth, data, params }) {
21+
if (auth.type === 'client' && auth.user?.id !== params.user_id) {
22+
throw new StatusError(StatusError.Forbidden, "Client can only access its own connected accounts");
23+
}
24+
25+
const providerRaw = Object.entries(auth.tenancy.config.auth.oauth.providers).find(([providerId, _]) => providerId === params.provider_id);
26+
if (!providerRaw) {
27+
throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled();
28+
}
29+
30+
const provider = { id: providerRaw[0], ...providerRaw[1] };
31+
32+
if (provider.isShared && !getNodeEnvironment().includes('prod') && getEnvVariable('STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS', '') !== 'true') {
33+
throw new KnownErrors.OAuthAccessTokenNotAvailableWithSharedOAuthKeys();
34+
}
35+
36+
const user = await usersCrudHandlers.adminRead({ tenancy: auth.tenancy, user_id: params.user_id });
37+
38+
const matchingProvider = user.oauth_providers.find(
39+
p => p.id === params.provider_id && p.account_id === params.provider_account_id
40+
);
41+
if (!matchingProvider) {
42+
throw new KnownErrors.OAuthConnectionNotConnectedToUser();
43+
}
44+
45+
const providerInstance = await getProvider(provider);
46+
const prisma = await getPrismaClientForTenancy(auth.tenancy);
47+
48+
const oauthAccount = await prisma.projectUserOAuthAccount.findFirst({
49+
where: {
50+
tenancyId: auth.tenancy.id,
51+
projectUserId: params.user_id,
52+
configOAuthProviderId: params.provider_id,
53+
providerAccountId: params.provider_account_id,
54+
allowConnectedAccounts: true,
55+
},
56+
});
57+
58+
if (!oauthAccount) {
59+
throw new KnownErrors.OAuthConnectionNotConnectedToUser();
60+
}
61+
62+
return await retrieveOrRefreshAccessToken({
63+
prisma,
64+
providerInstance,
65+
tenancyId: auth.tenancy.id,
66+
oauthAccountIds: [oauthAccount.id],
67+
scope: data.scope,
68+
errorContext: {
69+
tenancyId: auth.tenancy.id,
70+
providerId: params.provider_id,
71+
providerAccountId: params.provider_account_id,
72+
userId: params.user_id,
73+
scope: data.scope,
74+
},
75+
});
76+
},
77+
}));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { connectedAccountAccessTokenByAccountCrudHandlers } from "./crud";
2+
3+
export const POST = connectedAccountAccessTokenByAccountCrudHandlers.createHandler;
Lines changed: 21 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
import { usersCrudHandlers } from "@/app/api/latest/users/crud";
22
import { getProvider } from "@/oauth";
3-
import { TokenSet } from "@/oauth/providers/base";
43
import { getPrismaClientForTenancy } from "@/prisma-client";
54
import { createCrudHandlers } from "@/route-handlers/crud-handler";
65
import { KnownErrors } from "@stackframe/stack-shared";
76
import { connectedAccountAccessTokenCrud } from "@stackframe/stack-shared/dist/interface/crud/connected-accounts";
87
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
98
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
10-
import { StackAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
9+
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
1110
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
12-
import { Result } from "@stackframe/stack-shared/dist/utils/results";
13-
import { extractScopes } from "@stackframe/stack-shared/dist/utils/strings";
11+
import { retrieveOrRefreshAccessToken } from "../../../access-token-helpers";
1412

1513

1614
export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => createCrudHandlers(connectedAccountAccessTokenCrud, {
@@ -40,136 +38,34 @@ export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => cre
4038
}
4139

4240
const providerInstance = await getProvider(provider);
43-
44-
// ====================== retrieve access token if it exists ======================
4541
const prisma = await getPrismaClientForTenancy(auth.tenancy);
46-
const accessTokens = await prisma.oAuthAccessToken.findMany({
42+
43+
// Legacy endpoint: search tokens across ALL accounts for this provider and user
44+
const oauthAccounts = await prisma.projectUserOAuthAccount.findMany({
4745
where: {
4846
tenancyId: auth.tenancy.id,
49-
projectUserOAuthAccount: {
50-
projectUserId: params.user_id,
51-
configOAuthProviderId: params.provider_id,
52-
},
53-
expiresAt: {
54-
// is at least 5 minutes in the future
55-
gt: new Date(Date.now() + 5 * 60 * 1000),
56-
},
57-
isValid: true,
58-
},
59-
include: {
60-
projectUserOAuthAccount: true,
47+
projectUserId: params.user_id,
48+
configOAuthProviderId: params.provider_id,
6149
},
50+
select: { id: true },
6251
});
63-
const filteredTokens = accessTokens.filter((t) => {
64-
return extractScopes(data.scope || "").every((scope) => t.scopes.includes(scope));
65-
});
66-
for (const token of filteredTokens) {
67-
// some providers (particularly GitHub) invalidate access tokens on the server-side, in which case we want to request a new access token
68-
if (await providerInstance.checkAccessTokenValidity(token.accessToken)) {
69-
return { access_token: token.accessToken };
70-
} else {
71-
// mark the token as invalid
72-
await prisma.oAuthAccessToken.update({
73-
where: {
74-
id: token.id,
75-
},
76-
data: {
77-
isValid: false,
78-
},
79-
});
80-
}
81-
}
8252

83-
// ============== no valid access token found, try to refresh the token ==============
53+
if (oauthAccounts.length === 0) {
54+
throw new KnownErrors.OAuthConnectionNotConnectedToUser();
55+
}
8456

85-
const refreshTokens = await prisma.oAuthToken.findMany({
86-
where: {
57+
return await retrieveOrRefreshAccessToken({
58+
prisma,
59+
providerInstance,
60+
tenancyId: auth.tenancy.id,
61+
oauthAccountIds: oauthAccounts.map(a => a.id),
62+
scope: data.scope,
63+
errorContext: {
8764
tenancyId: auth.tenancy.id,
88-
projectUserOAuthAccount: {
89-
projectUserId: params.user_id,
90-
configOAuthProviderId: params.provider_id,
91-
},
92-
isValid: true,
93-
},
94-
include: {
95-
projectUserOAuthAccount: true,
65+
providerId: params.provider_id,
66+
userId: params.user_id,
67+
scope: data.scope,
9668
},
9769
});
98-
99-
const filteredRefreshTokens = refreshTokens.filter((t) => {
100-
return extractScopes(data.scope || "").every((scope) => t.scopes.includes(scope));
101-
});
102-
103-
if (filteredRefreshTokens.length === 0) {
104-
throw new KnownErrors.OAuthConnectionDoesNotHaveRequiredScope();
105-
}
106-
107-
for (const token of filteredRefreshTokens) {
108-
let tokenSetResult: Result<TokenSet, string>;
109-
try {
110-
tokenSetResult = await providerInstance.getAccessToken({
111-
refreshToken: token.refreshToken,
112-
scope: data.scope,
113-
});
114-
} catch (error) {
115-
// Unexpected errors (not handled by the provider) are logged and we continue to the next token
116-
captureError('oauth-access-token-refresh-unexpected-error', new StackAssertionError('Unexpected error refreshing access token — this may indicate a bug or misconfiguration', {
117-
error,
118-
tenancyId: auth.tenancy.id,
119-
providerId: params.provider_id,
120-
userId: params.user_id,
121-
scope: data.scope,
122-
}));
123-
124-
tokenSetResult = Result.error("Unexpected error refreshing access token");
125-
}
126-
127-
if (tokenSetResult.status === "error") {
128-
await prisma.oAuthToken.update({
129-
where: { id: token.id },
130-
data: { isValid: false },
131-
});
132-
133-
continue;
134-
}
135-
136-
const tokenSet = tokenSetResult.data;
137-
if (tokenSet.accessToken) {
138-
await prisma.oAuthAccessToken.create({
139-
data: {
140-
tenancyId: auth.tenancy.id,
141-
accessToken: tokenSet.accessToken,
142-
oauthAccountId: token.projectUserOAuthAccount.id,
143-
scopes: token.scopes,
144-
expiresAt: tokenSet.accessTokenExpiredAt
145-
}
146-
});
147-
148-
if (tokenSet.refreshToken) {
149-
// mark the old token as invalid, add the new token to the DB
150-
const oldToken = token;
151-
await prisma.oAuthToken.update({
152-
where: { id: oldToken.id },
153-
data: { isValid: false },
154-
});
155-
await prisma.oAuthToken.create({
156-
data: {
157-
tenancyId: auth.tenancy.id,
158-
refreshToken: tokenSet.refreshToken,
159-
oauthAccountId: oldToken.projectUserOAuthAccount.id,
160-
scopes: oldToken.scopes,
161-
}
162-
});
163-
}
164-
165-
return { access_token: tokenSet.accessToken };
166-
} else {
167-
throw new StackAssertionError("No access token returned");
168-
}
169-
}
170-
171-
throw new KnownErrors.OAuthConnectionDoesNotHaveRequiredScope();
17270
},
17371
}));
174-
175-
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { ensureUserExists } from "@/lib/request-checks";
2+
import { getPrismaClientForTenancy } from "@/prisma-client";
3+
import { createCrudHandlers } from "@/route-handlers/crud-handler";
4+
import { KnownErrors } from "@stackframe/stack-shared";
5+
import { connectedAccountCrud } from "@stackframe/stack-shared/dist/interface/crud/connected-accounts";
6+
import { userIdOrMeSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
7+
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
8+
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
9+
10+
export const connectedAccountCrudHandlers = createLazyProxy(() => createCrudHandlers(connectedAccountCrud, {
11+
paramsSchema: yupObject({
12+
user_id: userIdOrMeSchema.defined(),
13+
}),
14+
async onList({ auth, params }) {
15+
const userId = params.user_id;
16+
17+
if (auth.type === 'client') {
18+
const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser());
19+
if (!userId || currentUserId !== userId) {
20+
throw new StatusError(StatusError.Forbidden, 'Client can only list connected accounts for their own user.');
21+
}
22+
}
23+
24+
const prismaClient = await getPrismaClientForTenancy(auth.tenancy);
25+
26+
if (userId) {
27+
await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId });
28+
}
29+
30+
const oauthAccounts = await prismaClient.projectUserOAuthAccount.findMany({
31+
where: {
32+
tenancyId: auth.tenancy.id,
33+
projectUserId: userId,
34+
allowConnectedAccounts: true,
35+
},
36+
orderBy: {
37+
createdAt: 'asc',
38+
},
39+
});
40+
41+
return {
42+
items: oauthAccounts.map((oauthAccount) => ({
43+
user_id: oauthAccount.projectUserId ?? throwErr("OAuth account has no project user ID"),
44+
provider: oauthAccount.configOAuthProviderId,
45+
provider_account_id: oauthAccount.providerAccountId,
46+
})),
47+
is_paginated: false,
48+
};
49+
},
50+
}));
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { connectedAccountCrudHandlers } from "./crud";
2+
3+
export const GET = connectedAccountCrudHandlers.listHandler;

0 commit comments

Comments
 (0)