Skip to content

Allow refresh: true in getAccessToken() #2055

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Apr 25, 2025
59 changes: 59 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,65 @@ export async function middleware(request: NextRequest) {
}
```

### Forcing Access Token Refresh

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.

The `getAccessToken` method provides an option to force this refresh.

**App Router (Server Components, Route Handlers, Server Actions):**

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.

```typescript
// app/api/my-api/route.ts
import { getAccessToken } from '@auth0/nextjs-auth0';

export async function GET() {
try {
// Force a refresh of the access token
const { token, expiresAt } = await getAccessToken({ refresh: true });

// Use the refreshed token
// ...
} catch (error) {
console.error('Error getting access token:', error);
return Response.json({ error: 'Failed to get access token' }, { status: 500 });
}
}
```

**Pages Router (getServerSideProps, API Routes):**

When calling `getAccessToken` with request and response objects (from `getServerSideProps` context or an API route), the options object is passed as the third argument.

```typescript
// pages/api/my-pages-api.ts
import { getAccessToken, withApiAuthRequired } from '@auth0/nextjs-auth0';
import type { NextApiRequest, NextApiResponse } from 'next';

export default withApiAuthRequired(async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
// Force a refresh of the access token
const { token, expiresAt } = await getAccessToken(req, res, {
refresh: true
});

// Use the refreshed token
// ...
} catch (error: any) {
console.error('Error getting access token:', error);
res.status(error.status || 500).json({ error: error.message });
}
});
```

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.
This will in turn, update the `access_token`, `id_token` and `expires_at` fields of `tokenset` in the session.

## `<Auth0Provider />`

### Passing an initial user from the server
Expand Down
103 changes: 53 additions & 50 deletions src/server/auth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,7 +668,8 @@ export class AuthClient {
* refresh it using the refresh token, if available.
*/
async getTokenSet(
tokenSet: TokenSet
tokenSet: TokenSet,
forceRefresh?: boolean | undefined
): Promise<[null, TokenSet] | [SdkError, null]> {
// the access token has expired but we do not have a refresh token
if (!tokenSet.refreshToken && tokenSet.expiresAt <= Date.now() / 1000) {
Expand All @@ -681,65 +682,67 @@ export class AuthClient {
];
}

// the access token has expired and we have a refresh token
if (tokenSet.refreshToken && tokenSet.expiresAt <= Date.now() / 1000) {
const [discoveryError, authorizationServerMetadata] =
await this.discoverAuthorizationServerMetadata();

if (discoveryError) {
console.error(discoveryError);
return [discoveryError, null];
}
if (tokenSet.refreshToken) {
// either the access token has expired or we are forcing a refresh
if (forceRefresh || tokenSet.expiresAt <= Date.now() / 1000) {
const [discoveryError, authorizationServerMetadata] =
await this.discoverAuthorizationServerMetadata();

const refreshTokenRes = await oauth.refreshTokenGrantRequest(
authorizationServerMetadata,
this.clientMetadata,
await this.getClientAuth(),
tokenSet.refreshToken,
{
...this.httpOptions(),
[oauth.customFetch]: this.fetch,
[oauth.allowInsecureRequests]: this.allowInsecureRequests
if (discoveryError) {
console.error(discoveryError);
return [discoveryError, null];
}
);

let oauthRes: oauth.TokenEndpointResponse;
try {
oauthRes = await oauth.processRefreshTokenResponse(
const refreshTokenRes = await oauth.refreshTokenGrantRequest(
authorizationServerMetadata,
this.clientMetadata,
refreshTokenRes
await this.getClientAuth(),
tokenSet.refreshToken,
{
...this.httpOptions(),
[oauth.customFetch]: this.fetch,
[oauth.allowInsecureRequests]: this.allowInsecureRequests
}
);
} catch (e: any) {
console.error(e);
return [
new AccessTokenError(
AccessTokenErrorCode.FAILED_TO_REFRESH_TOKEN,
"The access token has expired and there was an error while trying to refresh it. Check the server logs for more information."
),
null
];
}

const accessTokenExpiresAt =
Math.floor(Date.now() / 1000) + Number(oauthRes.expires_in);
let oauthRes: oauth.TokenEndpointResponse;
try {
oauthRes = await oauth.processRefreshTokenResponse(
authorizationServerMetadata,
this.clientMetadata,
refreshTokenRes
);
} catch (e: any) {
console.error(e);
return [
new AccessTokenError(
AccessTokenErrorCode.FAILED_TO_REFRESH_TOKEN,
"The access token has expired and there was an error while trying to refresh it. Check the server logs for more information."
),
null
];
}

const updatedTokenSet = {
...tokenSet, // contains the existing `iat` claim to maintain the session lifetime
accessToken: oauthRes.access_token,
idToken: oauthRes.id_token,
expiresAt: accessTokenExpiresAt
};
const accessTokenExpiresAt =
Math.floor(Date.now() / 1000) + Number(oauthRes.expires_in);

const updatedTokenSet = {
...tokenSet, // contains the existing `iat` claim to maintain the session lifetime
accessToken: oauthRes.access_token,
idToken: oauthRes.id_token,
expiresAt: accessTokenExpiresAt
};

if (oauthRes.refresh_token) {
// refresh token rotation is enabled, persist the new refresh token from the response
updatedTokenSet.refreshToken = oauthRes.refresh_token;
} else {
// we did not get a refresh token back, keep the current long-lived refresh token around
updatedTokenSet.refreshToken = tokenSet.refreshToken;
}

if (oauthRes.refresh_token) {
// refresh token rotation is enabled, persist the new refresh token from the response
updatedTokenSet.refreshToken = oauthRes.refresh_token;
} else {
// we did not get a refresh token back, keep the current long-lived refresh token around
updatedTokenSet.refreshToken = tokenSet.refreshToken;
return [null, updatedTokenSet];
}

return [null, updatedTokenSet];
}

return [null, tokenSet];
Expand Down
125 changes: 114 additions & 11 deletions src/server/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
import { NextResponse, type NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

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

// Define ENV_VARS at the top level for broader scope
const ENV_VARS = {
DOMAIN: "AUTH0_DOMAIN",
CLIENT_ID: "AUTH0_CLIENT_ID",
CLIENT_SECRET: "AUTH0_CLIENT_SECRET",
CLIENT_ASSERTION_SIGNING_KEY: "AUTH0_CLIENT_ASSERTION_SIGNING_KEY",
APP_BASE_URL: "APP_BASE_URL",
SECRET: "AUTH0_SECRET",
SCOPE: "AUTH0_SCOPE"
};

describe("Auth0Client", () => {
// Store original env vars
const originalEnv = { ...process.env };

// Define correct environment variable names
const ENV_VARS = {
DOMAIN: "AUTH0_DOMAIN",
CLIENT_ID: "AUTH0_CLIENT_ID",
CLIENT_SECRET: "AUTH0_CLIENT_SECRET",
CLIENT_ASSERTION_SIGNING_KEY: "AUTH0_CLIENT_ASSERTION_SIGNING_KEY",
APP_BASE_URL: "APP_BASE_URL",
SECRET: "AUTH0_SECRET",
SCOPE: "AUTH0_SCOPE"
};

// Clear env vars before each test
beforeEach(() => {
vi.resetModules();
Expand All @@ -33,6 +37,7 @@ describe("Auth0Client", () => {
// Restore env vars after each test
afterEach(() => {
process.env = { ...originalEnv };
vi.restoreAllMocks(); // Restore mocks created within tests/beforeEach
});

describe("constructor validation", () => {
Expand Down Expand Up @@ -111,4 +116,102 @@ describe("Auth0Client", () => {
}
});
});

describe("getAccessToken", () => {
const mockSession: SessionData = {
user: { sub: "user123" },
tokenSet: {
accessToken: "old_access_token",
idToken: "old_id_token",
refreshToken: "old_refresh_token",
expiresAt: Date.now() / 1000 - 3600 // Expired
},
internal: {
sid: "mock_sid",
createdAt: Date.now() / 1000 - 7200 // Some time in the past
},
createdAt: Date.now() / 1000
};

// Restore original mock for refreshed token set
const mockRefreshedTokenSet = {
accessToken: "new_access_token",
idToken: "new_id_token",
refreshToken: "new_refresh_token",
expiresAt: Date.now() / 1000 + 3600, // Not expired
scope: "openid profile email"
};

let client: Auth0Client;
let mockGetSession: ReturnType<typeof vi.spyOn>;
let mockSaveToSession: ReturnType<typeof vi.spyOn>;
let mockGetTokenSet: ReturnType<typeof vi.spyOn>; // Re-declare mockGetTokenSet

beforeEach(() => {
// Reset mocks specifically if vi.restoreAllMocks isn't enough
// vi.resetAllMocks(); // Alternative to restoreAllMocks in afterEach

// Set necessary environment variables
process.env[ENV_VARS.DOMAIN] = "test.auth0.com";
process.env[ENV_VARS.CLIENT_ID] = "test_client_id";
process.env[ENV_VARS.CLIENT_SECRET] = "test_client_secret";
process.env[ENV_VARS.APP_BASE_URL] = "https://myapp.test";
process.env[ENV_VARS.SECRET] = "test_secret";

client = new Auth0Client();

// Mock internal methods of Auth0Client
mockGetSession = vi
.spyOn(Auth0Client.prototype as any, "getSession")
.mockResolvedValue(mockSession);
mockSaveToSession = vi
.spyOn(Auth0Client.prototype as any, "saveToSession")
.mockResolvedValue(undefined);

// Restore mocking of getTokenSet directly
mockGetTokenSet = vi
.spyOn(AuthClient.prototype as any, "getTokenSet")
.mockResolvedValue([null, mockRefreshedTokenSet]); // Simulate successful refresh

// Remove mocks for discoverAuthorizationServerMetadata and getClientAuth
// Remove fetch mock
});

it("should throw AccessTokenError if no session exists", async () => {
// Override getSession mock for this specific test
mockGetSession.mockResolvedValue(null);

// Mock request and response objects
const mockReq = { headers: new Headers() } as NextRequest;
const mockRes = new NextResponse();

await expect(
client.getAccessToken(mockReq, mockRes)
).rejects.toThrowError(
new AccessTokenError(
AccessTokenErrorCode.MISSING_SESSION,
"The user does not have an active session."
)
);
// Ensure getTokenSet was not called
expect(mockGetTokenSet).not.toHaveBeenCalled();
});

it("should throw error from getTokenSet if refresh fails", async () => {
const refreshError = new Error("Refresh failed");
// Restore overriding the getTokenSet mock directly
mockGetTokenSet.mockResolvedValue([refreshError, null]);

// Mock request and response objects
const mockReq = { headers: new Headers() } as NextRequest;
const mockRes = new NextResponse();

await expect(
client.getAccessToken(mockReq, mockRes, { refresh: true })
).rejects.toThrowError(refreshError);

// Verify save was not called
expect(mockSaveToSession).not.toHaveBeenCalled();
});
});
});
Loading