|
1 | | -import { OAuthBaseProvider, TokenSet } from "@/oauth/providers/base"; |
| 1 | +import { OAuthBaseProvider } from "@/oauth/providers/base"; |
| 2 | +import type { OAuthAccessTokenRefreshError } from "@/oauth/providers/base"; |
2 | 3 | import { getPrismaClientForTenancy } from "@/prisma-client"; |
3 | 4 | import { KnownErrors } from "@stackframe/stack-shared"; |
4 | 5 | import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; |
5 | | -import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; |
6 | | -import { Result } from "@stackframe/stack-shared/dist/utils/results"; |
| 6 | +import { StackAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; |
7 | 7 | import { extractScopes } from "@stackframe/stack-shared/dist/utils/strings"; |
8 | 8 |
|
| 9 | +function captureOAuthAccessTokenRefreshIssue(options: { |
| 10 | + location: string, |
| 11 | + message: string, |
| 12 | + providerInstance: OAuthBaseProvider, |
| 13 | + errorContext: Record<string, unknown>, |
| 14 | + refreshError: Exclude<OAuthAccessTokenRefreshError, { type: "invalid-refresh-token" }>, |
| 15 | +}) { |
| 16 | + const providerId = typeof options.errorContext.providerId === "string" ? options.errorContext.providerId : "unknown"; |
| 17 | + const providerClass = options.providerInstance.constructor.name; |
| 18 | + captureError(options.location, new StackAssertionError( |
| 19 | + `${options.message} (providerId: ${providerId}, providerClass: ${providerClass}, attempts: ${options.refreshError.attempts}, retries: ${options.refreshError.retryCount})`, |
| 20 | + { |
| 21 | + cause: options.refreshError.cause, |
| 22 | + ...options.errorContext, |
| 23 | + providerId, |
| 24 | + providerClass, |
| 25 | + refreshErrorType: options.refreshError.type, |
| 26 | + attempts: options.refreshError.attempts, |
| 27 | + retryCount: options.refreshError.retryCount, |
| 28 | + sawAmbiguousRefreshAttempt: options.refreshError.sawAmbiguousRefreshAttempt, |
| 29 | + error: options.refreshError.cause, |
| 30 | + causes: options.refreshError.causes, |
| 31 | + }, |
| 32 | + )); |
| 33 | +} |
| 34 | + |
9 | 35 | /** |
10 | 36 | * Access tokens minted under Stack Auth's shared OAuth apps must not be handed |
11 | 37 | * to clients — they carry Stack Auth's brand at the provider. Only allowed when |
@@ -122,27 +148,71 @@ export async function retrieveOrRefreshAccessToken(options: { |
122 | 148 | } |
123 | 149 |
|
124 | 150 | for (const token of filteredRefreshTokens) { |
125 | | - let tokenSetResult: Result<TokenSet, string>; |
126 | | - try { |
127 | | - tokenSetResult = await providerInstance.getAccessToken({ |
128 | | - refreshToken: token.refreshToken, |
129 | | - scope, |
130 | | - }); |
131 | | - } catch (error) { |
132 | | - captureError('oauth-access-token-refresh-unexpected-error', new StackAssertionError('Unexpected error refreshing access token — this may indicate a bug or misconfiguration', { |
133 | | - error, |
134 | | - ...errorContext, |
135 | | - })); |
136 | | - |
137 | | - tokenSetResult = Result.error("Unexpected error refreshing access token"); |
138 | | - } |
| 151 | + const tokenSetResult = await providerInstance.getAccessToken({ |
| 152 | + refreshToken: token.refreshToken, |
| 153 | + scope, |
| 154 | + }); |
139 | 155 |
|
140 | 156 | if (tokenSetResult.status === "error") { |
141 | | - await prisma.oAuthToken.update({ |
142 | | - where: { id: token.id }, |
143 | | - data: { isValid: false }, |
144 | | - }); |
145 | | - continue; |
| 157 | + switch (tokenSetResult.error.type) { |
| 158 | + case "invalid-refresh-token": { |
| 159 | + // Only this outcome proves that the stored refresh token should no |
| 160 | + // longer be used. Provider timeouts and retry ambiguity must not |
| 161 | + // invalidate the connection; the user usually cannot fix those. |
| 162 | + await prisma.oAuthToken.update({ |
| 163 | + where: { id: token.id }, |
| 164 | + data: { isValid: false }, |
| 165 | + }); |
| 166 | + continue; |
| 167 | + } |
| 168 | + case "temporarily-unavailable": { |
| 169 | + // The customer should retry later. Do not mark the OAuth connection as |
| 170 | + // broken or ask the user to reconnect based on a transient provider |
| 171 | + // failure. |
| 172 | + captureOAuthAccessTokenRefreshIssue({ |
| 173 | + location: "oauth-access-token-refresh-provider-temporarily-unavailable", |
| 174 | + message: "OAuth provider temporarily unavailable during access token refresh", |
| 175 | + providerInstance, |
| 176 | + errorContext, |
| 177 | + refreshError: tokenSetResult.error, |
| 178 | + }); |
| 179 | + throw new KnownErrors.OAuthProviderTemporarilyUnavailable(); |
| 180 | + } |
| 181 | + case "invalid-client": { |
| 182 | + captureOAuthAccessTokenRefreshIssue({ |
| 183 | + location: "oauth-access-token-refresh-invalid-client", |
| 184 | + message: "OAuth provider rejected configured client during access token refresh", |
| 185 | + providerInstance, |
| 186 | + errorContext, |
| 187 | + refreshError: tokenSetResult.error, |
| 188 | + }); |
| 189 | + throw new StatusError(400, `Invalid client credentials for this OAuth provider. Please ensure the configuration in the Stack Auth dashboard is correct.`); |
| 190 | + } |
| 191 | + case "unexpected": { |
| 192 | + captureOAuthAccessTokenRefreshIssue({ |
| 193 | + location: "oauth-access-token-refresh-unexpected-error", |
| 194 | + message: "Unexpected OAuth provider error during access token refresh", |
| 195 | + providerInstance, |
| 196 | + errorContext, |
| 197 | + refreshError: tokenSetResult.error, |
| 198 | + }); |
| 199 | + const assertionError = new StackAssertionError('Unexpected error refreshing access token — this may indicate a bug or misconfiguration', { |
| 200 | + error: tokenSetResult.error.cause, |
| 201 | + providerClass: providerInstance.constructor.name, |
| 202 | + refreshErrorType: tokenSetResult.error.type, |
| 203 | + attempts: tokenSetResult.error.attempts, |
| 204 | + retryCount: tokenSetResult.error.retryCount, |
| 205 | + sawAmbiguousRefreshAttempt: tokenSetResult.error.sawAmbiguousRefreshAttempt, |
| 206 | + causes: tokenSetResult.error.causes, |
| 207 | + ...errorContext, |
| 208 | + }); |
| 209 | + throw assertionError; |
| 210 | + } |
| 211 | + default: { |
| 212 | + const _: never = tokenSetResult.error; |
| 213 | + throw new StackAssertionError("Unhandled OAuth access token refresh error", { error: _ }); |
| 214 | + } |
| 215 | + } |
146 | 216 | } |
147 | 217 |
|
148 | 218 | const tokenSet = tokenSetResult.data; |
|
0 commit comments