Skip to content

Commit a8cb74f

Browse files
authored
Merge branch 'main' into support/nextjs-16
2 parents 3cef8d8 + 6deb16d commit a8cb74f

File tree

2 files changed

+149
-111
lines changed

2 files changed

+149
-111
lines changed

.github/workflows/playwright.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626

2727
- name: Run Playwright tests
2828
run: pnpm exec playwright test
29-
- uses: actions/upload-artifact@v4
29+
- uses: actions/upload-artifact@v5
3030
if: ${{ !cancelled() }}
3131
with:
3232
name: playwright-report

src/server/auth-client.ts

Lines changed: 148 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,122 +1169,21 @@ export class AuthClient {
11691169
!tokenSet.expiresAt ||
11701170
tokenSet.expiresAt <= Date.now() / 1000
11711171
) {
1172-
const [discoveryError, authorizationServerMetadata] =
1173-
await this.discoverAuthorizationServerMetadata();
1174-
1175-
if (discoveryError) {
1176-
return [discoveryError, null];
1177-
}
1178-
1179-
const additionalParameters = new URLSearchParams();
1180-
1181-
if (options.scope) {
1182-
additionalParameters.append("scope", scope);
1183-
}
1184-
1185-
if (options.audience) {
1186-
additionalParameters.append("audience", options.audience);
1187-
}
1188-
1189-
// Create DPoP handle ONCE outside the closure so it persists across retries.
1190-
// This is required by RFC 9449: the handle must learn and reuse the nonce from
1191-
// the DPoP-Nonce header across multiple attempts.
1192-
const dpopHandle =
1193-
this.useDPoP && this.dpopKeyPair
1194-
? oauth.DPoP(this.clientMetadata, this.dpopKeyPair)
1195-
: undefined;
1196-
1197-
const refreshTokenGrantRequestCall = async () =>
1198-
oauth.refreshTokenGrantRequest(
1199-
authorizationServerMetadata,
1200-
this.clientMetadata,
1201-
await this.getClientAuth(),
1202-
tokenSet.refreshToken!,
1203-
{
1204-
...this.httpOptions(),
1205-
[oauth.customFetch]: this.fetch,
1206-
[oauth.allowInsecureRequests]: this.allowInsecureRequests,
1207-
additionalParameters,
1208-
...(dpopHandle && {
1209-
DPoP: dpopHandle
1210-
})
1211-
}
1212-
);
1213-
1214-
const processRefreshTokenResponseCall = (response: Response) =>
1215-
oauth.processRefreshTokenResponse(
1216-
authorizationServerMetadata,
1217-
this.clientMetadata,
1218-
response
1219-
);
1220-
1221-
let oauthRes: oauth.TokenEndpointResponse;
1222-
try {
1223-
oauthRes = await withDPoPNonceRetry(
1224-
async () => {
1225-
const refreshTokenRes = await refreshTokenGrantRequestCall();
1226-
return await processRefreshTokenResponseCall(refreshTokenRes);
1227-
},
1228-
{
1229-
isDPoPEnabled: !!(this.useDPoP && this.dpopKeyPair),
1230-
...this.dpopOptions?.retry
1231-
}
1232-
);
1233-
} catch (e: any) {
1234-
return [
1235-
new AccessTokenError(
1236-
AccessTokenErrorCode.FAILED_TO_REFRESH_TOKEN,
1237-
"The access token has expired and there was an error while trying to refresh it.",
1238-
new OAuth2Error({
1239-
code: e.error,
1240-
message: e.error_description
1241-
})
1242-
),
1243-
null
1244-
];
1245-
}
1172+
const [error, response] = await this.#refreshTokenSet(tokenSet, {
1173+
audience: options.audience,
1174+
scope: options.scope ? scope : undefined,
1175+
requestedScope: scope
1176+
});
12461177

1247-
const idTokenClaims = oauth.getValidatedIdTokenClaims(oauthRes)!;
1248-
const accessTokenExpiresAt =
1249-
Math.floor(Date.now() / 1000) + Number(oauthRes.expires_in);
1250-
1251-
const updatedTokenSet = {
1252-
...tokenSet, // contains the existing `iat` claim to maintain the session lifetime
1253-
accessToken: oauthRes.access_token,
1254-
idToken: oauthRes.id_token,
1255-
// We store the both requested and granted scopes on the tokenSet, so we know what scopes were requested.
1256-
// The server may return less scopes than requested.
1257-
// This ensures we can return the same token again when a token for the same or less scopes is requested by using `requestedScope` during look-up.
1258-
//
1259-
// E.g. When requesting a token with scope `a b`, and we return one for scope `a` only,
1260-
// - If we only store the returned scopes, we cannot return this token when the user requests a token for scope `a b` again.
1261-
// - If we only store the requested scopes, we lose track of the actual scopes granted.
1262-
//
1263-
// Scopes actually granted by the server
1264-
scope: oauthRes.scope,
1265-
// Scopes requested by the client
1266-
requestedScope: scope,
1267-
expiresAt: accessTokenExpiresAt,
1268-
// Keep the audience if it exists, otherwise use the one from the options.
1269-
// If not provided, use `undefined`.
1270-
audience: tokenSet.audience || options.audience || undefined,
1271-
// Store the token type from the OAuth response (e.g., "Bearer", "DPoP")
1272-
...(oauthRes.token_type && { token_type: oauthRes.token_type })
1273-
};
1274-
1275-
if (oauthRes.refresh_token) {
1276-
// refresh token rotation is enabled, persist the new refresh token from the response
1277-
updatedTokenSet.refreshToken = oauthRes.refresh_token;
1278-
} else {
1279-
// we did not get a refresh token back, keep the current long-lived refresh token around
1280-
updatedTokenSet.refreshToken = tokenSet.refreshToken;
1178+
if (error) {
1179+
return [error, null];
12811180
}
12821181

12831182
return [
12841183
null,
12851184
{
1286-
tokenSet: updatedTokenSet,
1287-
idTokenClaims: idTokenClaims
1185+
tokenSet: response.updatedTokenSet,
1186+
idTokenClaims: response.idTokenClaims
12881187
}
12891188
];
12901189
}
@@ -2241,6 +2140,145 @@ export class AuthClient {
22412140
addCacheControlHeadersForSession(res);
22422141
}
22432142
}
2143+
2144+
/**
2145+
* Refreshes the token set using the provided refresh token.
2146+
* @param tokenSet The current token set containing the refresh token.
2147+
* @param options Options for the refresh operation, including scope and audience.
2148+
* @returns A tuple containing either:
2149+
* - `[null, { updatedTokenSet: TokenSet; idTokenClaims: oauth.IDToken }]` if the token was successfully refreshed, containing the updated token set and ID token claims.
2150+
* - `[SdkError, null]` if an error occurred during the refresh process.
2151+
*/
2152+
async #refreshTokenSet(
2153+
tokenSet: Partial<TokenSet>,
2154+
options: {
2155+
scope?: string;
2156+
audience?: string | null;
2157+
requestedScope: string;
2158+
}
2159+
): Promise<
2160+
| [null, { updatedTokenSet: TokenSet; idTokenClaims: oauth.IDToken }]
2161+
| [SdkError, null]
2162+
> {
2163+
const [discoveryError, authorizationServerMetadata] =
2164+
await this.discoverAuthorizationServerMetadata();
2165+
2166+
if (discoveryError) {
2167+
return [discoveryError, null];
2168+
}
2169+
2170+
const additionalParameters = new URLSearchParams();
2171+
2172+
if (options.scope) {
2173+
additionalParameters.append("scope", options.scope);
2174+
}
2175+
2176+
if (options.audience) {
2177+
additionalParameters.append("audience", options.audience);
2178+
}
2179+
2180+
// Create DPoP handle ONCE outside the closure so it persists across retries.
2181+
// This is required by RFC 9449: the handle must learn and reuse the nonce from
2182+
// the DPoP-Nonce header across multiple attempts.
2183+
const dpopHandle =
2184+
this.useDPoP && this.dpopKeyPair
2185+
? oauth.DPoP(this.clientMetadata, this.dpopKeyPair)
2186+
: undefined;
2187+
2188+
const refreshTokenGrantRequestCall = async () =>
2189+
oauth.refreshTokenGrantRequest(
2190+
authorizationServerMetadata,
2191+
this.clientMetadata,
2192+
await this.getClientAuth(),
2193+
tokenSet.refreshToken!,
2194+
{
2195+
...this.httpOptions(),
2196+
[oauth.customFetch]: this.fetch,
2197+
[oauth.allowInsecureRequests]: this.allowInsecureRequests,
2198+
additionalParameters,
2199+
...(dpopHandle && {
2200+
DPoP: dpopHandle
2201+
})
2202+
}
2203+
);
2204+
2205+
const processRefreshTokenResponseCall = (response: Response) =>
2206+
oauth.processRefreshTokenResponse(
2207+
authorizationServerMetadata,
2208+
this.clientMetadata,
2209+
response
2210+
);
2211+
2212+
let oauthRes: oauth.TokenEndpointResponse;
2213+
try {
2214+
oauthRes = await withDPoPNonceRetry(
2215+
async () => {
2216+
const refreshTokenRes = await refreshTokenGrantRequestCall();
2217+
return await processRefreshTokenResponseCall(refreshTokenRes);
2218+
},
2219+
{
2220+
isDPoPEnabled: !!(this.useDPoP && this.dpopKeyPair),
2221+
...this.dpopOptions?.retry
2222+
}
2223+
);
2224+
} catch (e: any) {
2225+
return [
2226+
new AccessTokenError(
2227+
AccessTokenErrorCode.FAILED_TO_REFRESH_TOKEN,
2228+
"The access token has expired and there was an error while trying to refresh it.",
2229+
new OAuth2Error({
2230+
code: e.error,
2231+
message: e.error_description
2232+
})
2233+
),
2234+
null
2235+
];
2236+
}
2237+
2238+
const idTokenClaims = oauth.getValidatedIdTokenClaims(oauthRes)!;
2239+
const accessTokenExpiresAt =
2240+
Math.floor(Date.now() / 1000) + Number(oauthRes.expires_in);
2241+
2242+
const updatedTokenSet = {
2243+
...tokenSet, // contains the existing `iat` claim to maintain the session lifetime
2244+
accessToken: oauthRes.access_token,
2245+
idToken: oauthRes.id_token,
2246+
// We store the both requested and granted scopes on the tokenSet, so we know what scopes were requested.
2247+
// The server may return less scopes than requested.
2248+
// This ensures we can return the same token again when a token for the same or less scopes is requested by using `requestedScope` during look-up.
2249+
//
2250+
// E.g. When requesting a token with scope `a b`, and we return one for scope `a` only,
2251+
// - If we only store the returned scopes, we cannot return this token when the user requests a token for scope `a b` again.
2252+
// - If we only store the requested scopes, we lose track of the actual scopes granted.
2253+
//
2254+
// Scopes actually granted by the server
2255+
scope: oauthRes.scope,
2256+
// Scopes requested by the client
2257+
requestedScope: options.requestedScope,
2258+
expiresAt: accessTokenExpiresAt,
2259+
// Keep the audience if it exists, otherwise use the one from the options.
2260+
// If not provided, use `undefined`.
2261+
audience: tokenSet.audience || options.audience || undefined,
2262+
// Store the token type from the OAuth response (e.g., "Bearer", "DPoP")
2263+
...(oauthRes.token_type && { token_type: oauthRes.token_type })
2264+
};
2265+
2266+
if (oauthRes.refresh_token) {
2267+
// refresh token rotation is enabled, persist the new refresh token from the response
2268+
updatedTokenSet.refreshToken = oauthRes.refresh_token;
2269+
} else {
2270+
// we did not get a refresh token back, keep the current long-lived refresh token around
2271+
updatedTokenSet.refreshToken = tokenSet.refreshToken;
2272+
}
2273+
2274+
return [
2275+
null,
2276+
{
2277+
updatedTokenSet,
2278+
idTokenClaims
2279+
}
2280+
];
2281+
}
22442282
}
22452283

22462284
const encodeBase64 = (input: string) => {

0 commit comments

Comments
 (0)