Skip to content

Commit da33ec8

Browse files
Allow refresh: true in getAccessToken() (#2055)
2 parents 64ae3d4 + 950edb9 commit da33ec8

File tree

5 files changed

+760
-67
lines changed

5 files changed

+760
-67
lines changed

EXAMPLES.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,65 @@ export async function middleware(request: NextRequest) {
520520
}
521521
```
522522

523+
### Forcing Access Token Refresh
524+
525+
In some scenarios, you might need to explicitly force the refresh of an access token, even if it hasn't expired yet. This can be useful if, for example, the user's permissions or scopes have changed and you need to ensure the application has the latest token reflecting these changes.
526+
527+
The `getAccessToken` method provides an option to force this refresh.
528+
529+
**App Router (Server Components, Route Handlers, Server Actions):**
530+
531+
When calling `getAccessToken` without request and response objects, you can pass an options object as the first argument. Set the `refresh` property to `true` to force a token refresh.
532+
533+
```typescript
534+
// app/api/my-api/route.ts
535+
import { getAccessToken } from '@auth0/nextjs-auth0';
536+
537+
export async function GET() {
538+
try {
539+
// Force a refresh of the access token
540+
const { token, expiresAt } = await getAccessToken({ refresh: true });
541+
542+
// Use the refreshed token
543+
// ...
544+
} catch (error) {
545+
console.error('Error getting access token:', error);
546+
return Response.json({ error: 'Failed to get access token' }, { status: 500 });
547+
}
548+
}
549+
```
550+
551+
**Pages Router (getServerSideProps, API Routes):**
552+
553+
When calling `getAccessToken` with request and response objects (from `getServerSideProps` context or an API route), the options object is passed as the third argument.
554+
555+
```typescript
556+
// pages/api/my-pages-api.ts
557+
import { getAccessToken, withApiAuthRequired } from '@auth0/nextjs-auth0';
558+
import type { NextApiRequest, NextApiResponse } from 'next';
559+
560+
export default withApiAuthRequired(async function handler(
561+
req: NextApiRequest,
562+
res: NextApiResponse
563+
) {
564+
try {
565+
// Force a refresh of the access token
566+
const { token, expiresAt } = await getAccessToken(req, res, {
567+
refresh: true
568+
});
569+
570+
// Use the refreshed token
571+
// ...
572+
} catch (error: any) {
573+
console.error('Error getting access token:', error);
574+
res.status(error.status || 500).json({ error: error.message });
575+
}
576+
});
577+
```
578+
579+
By setting `{ refresh: true }`, you instruct the SDK to bypass the standard expiration check and request a new access token from the identity provider using the refresh token (if available and valid). The new token set (including the potentially updated access token, refresh token, and expiration time) will be saved back into the session automatically.
580+
This will in turn, update the `access_token`, `id_token` and `expires_at` fields of `tokenset` in the session.
581+
523582
## `<Auth0Provider />`
524583

525584
### Passing an initial user from the server

src/server/auth-client.ts

Lines changed: 53 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -668,7 +668,8 @@ export class AuthClient {
668668
* refresh it using the refresh token, if available.
669669
*/
670670
async getTokenSet(
671-
tokenSet: TokenSet
671+
tokenSet: TokenSet,
672+
forceRefresh?: boolean | undefined
672673
): Promise<[null, TokenSet] | [SdkError, null]> {
673674
// the access token has expired but we do not have a refresh token
674675
if (!tokenSet.refreshToken && tokenSet.expiresAt <= Date.now() / 1000) {
@@ -681,65 +682,67 @@ export class AuthClient {
681682
];
682683
}
683684

684-
// the access token has expired and we have a refresh token
685-
if (tokenSet.refreshToken && tokenSet.expiresAt <= Date.now() / 1000) {
686-
const [discoveryError, authorizationServerMetadata] =
687-
await this.discoverAuthorizationServerMetadata();
688-
689-
if (discoveryError) {
690-
console.error(discoveryError);
691-
return [discoveryError, null];
692-
}
685+
if (tokenSet.refreshToken) {
686+
// either the access token has expired or we are forcing a refresh
687+
if (forceRefresh || tokenSet.expiresAt <= Date.now() / 1000) {
688+
const [discoveryError, authorizationServerMetadata] =
689+
await this.discoverAuthorizationServerMetadata();
693690

694-
const refreshTokenRes = await oauth.refreshTokenGrantRequest(
695-
authorizationServerMetadata,
696-
this.clientMetadata,
697-
await this.getClientAuth(),
698-
tokenSet.refreshToken,
699-
{
700-
...this.httpOptions(),
701-
[oauth.customFetch]: this.fetch,
702-
[oauth.allowInsecureRequests]: this.allowInsecureRequests
691+
if (discoveryError) {
692+
console.error(discoveryError);
693+
return [discoveryError, null];
703694
}
704-
);
705695

706-
let oauthRes: oauth.TokenEndpointResponse;
707-
try {
708-
oauthRes = await oauth.processRefreshTokenResponse(
696+
const refreshTokenRes = await oauth.refreshTokenGrantRequest(
709697
authorizationServerMetadata,
710698
this.clientMetadata,
711-
refreshTokenRes
699+
await this.getClientAuth(),
700+
tokenSet.refreshToken,
701+
{
702+
...this.httpOptions(),
703+
[oauth.customFetch]: this.fetch,
704+
[oauth.allowInsecureRequests]: this.allowInsecureRequests
705+
}
712706
);
713-
} catch (e: any) {
714-
console.error(e);
715-
return [
716-
new AccessTokenError(
717-
AccessTokenErrorCode.FAILED_TO_REFRESH_TOKEN,
718-
"The access token has expired and there was an error while trying to refresh it. Check the server logs for more information."
719-
),
720-
null
721-
];
722-
}
723707

724-
const accessTokenExpiresAt =
725-
Math.floor(Date.now() / 1000) + Number(oauthRes.expires_in);
708+
let oauthRes: oauth.TokenEndpointResponse;
709+
try {
710+
oauthRes = await oauth.processRefreshTokenResponse(
711+
authorizationServerMetadata,
712+
this.clientMetadata,
713+
refreshTokenRes
714+
);
715+
} catch (e: any) {
716+
console.error(e);
717+
return [
718+
new AccessTokenError(
719+
AccessTokenErrorCode.FAILED_TO_REFRESH_TOKEN,
720+
"The access token has expired and there was an error while trying to refresh it. Check the server logs for more information."
721+
),
722+
null
723+
];
724+
}
726725

727-
const updatedTokenSet = {
728-
...tokenSet, // contains the existing `iat` claim to maintain the session lifetime
729-
accessToken: oauthRes.access_token,
730-
idToken: oauthRes.id_token,
731-
expiresAt: accessTokenExpiresAt
732-
};
726+
const accessTokenExpiresAt =
727+
Math.floor(Date.now() / 1000) + Number(oauthRes.expires_in);
728+
729+
const updatedTokenSet = {
730+
...tokenSet, // contains the existing `iat` claim to maintain the session lifetime
731+
accessToken: oauthRes.access_token,
732+
idToken: oauthRes.id_token,
733+
expiresAt: accessTokenExpiresAt
734+
};
735+
736+
if (oauthRes.refresh_token) {
737+
// refresh token rotation is enabled, persist the new refresh token from the response
738+
updatedTokenSet.refreshToken = oauthRes.refresh_token;
739+
} else {
740+
// we did not get a refresh token back, keep the current long-lived refresh token around
741+
updatedTokenSet.refreshToken = tokenSet.refreshToken;
742+
}
733743

734-
if (oauthRes.refresh_token) {
735-
// refresh token rotation is enabled, persist the new refresh token from the response
736-
updatedTokenSet.refreshToken = oauthRes.refresh_token;
737-
} else {
738-
// we did not get a refresh token back, keep the current long-lived refresh token around
739-
updatedTokenSet.refreshToken = tokenSet.refreshToken;
744+
return [null, updatedTokenSet];
740745
}
741-
742-
return [null, updatedTokenSet];
743746
}
744747

745748
return [null, tokenSet];

src/server/client.test.ts

Lines changed: 114 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
1+
import { NextResponse, type NextRequest } from "next/server";
12
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
23

4+
import { AccessTokenError, AccessTokenErrorCode } from "../errors";
5+
import { SessionData } from "../types";
6+
import { AuthClient } from "./auth-client"; // Import the actual class for spyOn
37
import { Auth0Client } from "./client.js";
48

9+
// Define ENV_VARS at the top level for broader scope
10+
const ENV_VARS = {
11+
DOMAIN: "AUTH0_DOMAIN",
12+
CLIENT_ID: "AUTH0_CLIENT_ID",
13+
CLIENT_SECRET: "AUTH0_CLIENT_SECRET",
14+
CLIENT_ASSERTION_SIGNING_KEY: "AUTH0_CLIENT_ASSERTION_SIGNING_KEY",
15+
APP_BASE_URL: "APP_BASE_URL",
16+
SECRET: "AUTH0_SECRET",
17+
SCOPE: "AUTH0_SCOPE"
18+
};
19+
520
describe("Auth0Client", () => {
621
// Store original env vars
722
const originalEnv = { ...process.env };
823

9-
// Define correct environment variable names
10-
const ENV_VARS = {
11-
DOMAIN: "AUTH0_DOMAIN",
12-
CLIENT_ID: "AUTH0_CLIENT_ID",
13-
CLIENT_SECRET: "AUTH0_CLIENT_SECRET",
14-
CLIENT_ASSERTION_SIGNING_KEY: "AUTH0_CLIENT_ASSERTION_SIGNING_KEY",
15-
APP_BASE_URL: "APP_BASE_URL",
16-
SECRET: "AUTH0_SECRET",
17-
SCOPE: "AUTH0_SCOPE"
18-
};
19-
2024
// Clear env vars before each test
2125
beforeEach(() => {
2226
vi.resetModules();
@@ -33,6 +37,7 @@ describe("Auth0Client", () => {
3337
// Restore env vars after each test
3438
afterEach(() => {
3539
process.env = { ...originalEnv };
40+
vi.restoreAllMocks(); // Restore mocks created within tests/beforeEach
3641
});
3742

3843
describe("constructor validation", () => {
@@ -111,4 +116,102 @@ describe("Auth0Client", () => {
111116
}
112117
});
113118
});
119+
120+
describe("getAccessToken", () => {
121+
const mockSession: SessionData = {
122+
user: { sub: "user123" },
123+
tokenSet: {
124+
accessToken: "old_access_token",
125+
idToken: "old_id_token",
126+
refreshToken: "old_refresh_token",
127+
expiresAt: Date.now() / 1000 - 3600 // Expired
128+
},
129+
internal: {
130+
sid: "mock_sid",
131+
createdAt: Date.now() / 1000 - 7200 // Some time in the past
132+
},
133+
createdAt: Date.now() / 1000
134+
};
135+
136+
// Restore original mock for refreshed token set
137+
const mockRefreshedTokenSet = {
138+
accessToken: "new_access_token",
139+
idToken: "new_id_token",
140+
refreshToken: "new_refresh_token",
141+
expiresAt: Date.now() / 1000 + 3600, // Not expired
142+
scope: "openid profile email"
143+
};
144+
145+
let client: Auth0Client;
146+
let mockGetSession: ReturnType<typeof vi.spyOn>;
147+
let mockSaveToSession: ReturnType<typeof vi.spyOn>;
148+
let mockGetTokenSet: ReturnType<typeof vi.spyOn>; // Re-declare mockGetTokenSet
149+
150+
beforeEach(() => {
151+
// Reset mocks specifically if vi.restoreAllMocks isn't enough
152+
// vi.resetAllMocks(); // Alternative to restoreAllMocks in afterEach
153+
154+
// Set necessary environment variables
155+
process.env[ENV_VARS.DOMAIN] = "test.auth0.com";
156+
process.env[ENV_VARS.CLIENT_ID] = "test_client_id";
157+
process.env[ENV_VARS.CLIENT_SECRET] = "test_client_secret";
158+
process.env[ENV_VARS.APP_BASE_URL] = "https://myapp.test";
159+
process.env[ENV_VARS.SECRET] = "test_secret";
160+
161+
client = new Auth0Client();
162+
163+
// Mock internal methods of Auth0Client
164+
mockGetSession = vi
165+
.spyOn(Auth0Client.prototype as any, "getSession")
166+
.mockResolvedValue(mockSession);
167+
mockSaveToSession = vi
168+
.spyOn(Auth0Client.prototype as any, "saveToSession")
169+
.mockResolvedValue(undefined);
170+
171+
// Restore mocking of getTokenSet directly
172+
mockGetTokenSet = vi
173+
.spyOn(AuthClient.prototype as any, "getTokenSet")
174+
.mockResolvedValue([null, mockRefreshedTokenSet]); // Simulate successful refresh
175+
176+
// Remove mocks for discoverAuthorizationServerMetadata and getClientAuth
177+
// Remove fetch mock
178+
});
179+
180+
it("should throw AccessTokenError if no session exists", async () => {
181+
// Override getSession mock for this specific test
182+
mockGetSession.mockResolvedValue(null);
183+
184+
// Mock request and response objects
185+
const mockReq = { headers: new Headers() } as NextRequest;
186+
const mockRes = new NextResponse();
187+
188+
await expect(
189+
client.getAccessToken(mockReq, mockRes)
190+
).rejects.toThrowError(
191+
new AccessTokenError(
192+
AccessTokenErrorCode.MISSING_SESSION,
193+
"The user does not have an active session."
194+
)
195+
);
196+
// Ensure getTokenSet was not called
197+
expect(mockGetTokenSet).not.toHaveBeenCalled();
198+
});
199+
200+
it("should throw error from getTokenSet if refresh fails", async () => {
201+
const refreshError = new Error("Refresh failed");
202+
// Restore overriding the getTokenSet mock directly
203+
mockGetTokenSet.mockResolvedValue([refreshError, null]);
204+
205+
// Mock request and response objects
206+
const mockReq = { headers: new Headers() } as NextRequest;
207+
const mockRes = new NextResponse();
208+
209+
await expect(
210+
client.getAccessToken(mockReq, mockRes, { refresh: true })
211+
).rejects.toThrowError(refreshError);
212+
213+
// Verify save was not called
214+
expect(mockSaveToSession).not.toHaveBeenCalled();
215+
});
216+
});
114217
});

0 commit comments

Comments
 (0)