Skip to content
2 changes: 1 addition & 1 deletion .cursor/commands/pr-comments-review.md
Original file line number Diff line number Diff line change
@@ -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!
Please review the PR comments with `gh pr status` and fix & resolve those issues that are valid and relevant. 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!
Comment thread
N2D4 marked this conversation as resolved.
Outdated
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,
}
});
Comment thread
N2D4 marked this conversation as resolved.
Outdated
}

return { access_token: tokenSet.accessToken };
} else {
throw new StackAssertionError("No access token returned");
}
}

throw new KnownErrors.OAuthConnectionDoesNotHaveRequiredScope();
},
Comment thread
N2D4 marked this conversation as resolved.
}));
Comment thread
N2D4 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { connectedAccountAccessTokenByAccountCrudHandlers } from "./crud";

export const POST = connectedAccountAccessTokenByAccountCrudHandlers.createHandler;
Comment thread
N2D4 marked this conversation as resolved.
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.');
Comment thread
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,
};
},
}));
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { connectedAccountCrudHandlers } from "./crud";

export const GET = connectedAccountCrudHandlers.listHandler;
Comment thread
N2D4 marked this conversation as resolved.
2 changes: 1 addition & 1 deletion apps/dev-launchpad/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ <h2 style="margin-top: 64px;">Background services</h2>
if ((app.importance ?? 0) === importance) {
// TODO escape HTML
appContainer.innerHTML += `
<a href="http://localhost:${withPrefix(app.portSuffix)}${app.path ?? ""}" target="_blank" rel="noopener noreferrer" class="${app.importance === 2 ? "important" : app.importance === 1 ? "" : "unimportant"}">
<a href="http://${`${stackPortPrefix}` === "81" ? "" : `p${stackPortPrefix}.`}localhost:${withPrefix(app.portSuffix)}${app.path ?? ""}" target="_blank" rel="noopener noreferrer" class="${app.importance === 2 ? "important" : app.importance === 1 ? "" : "unimportant"}">
<div class="port">:${withPrefix(app.portSuffix)}</div>
<div>
<img src=${app.img || `//localhost:${withPrefix(app.portSuffix)}/favicon.ico`} />
Expand Down
Loading
Loading