Skip to content

Make force refresh AT also update the id token #2163

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -32,7 +32,6 @@
},
"homepage": "https://github.com/auth0/nextjs-auth0#readme",
"devDependencies": {
"msw": "^2.7.5",
"@eslint/js": "^9.20.0",
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
"@playwright/test": "^1.48.2",
@@ -48,6 +47,7 @@
"eslint-plugin-react": "^7.37.4",
"globals": "^15.14.0",
"jsdom": "^26.1.0",
"msw": "^2.7.5",
"next": "15.2.3",
"prettier": "^3.3.3",
"typedoc": "^0.28.4",
52 changes: 51 additions & 1 deletion src/server/auth-client.ts
Original file line number Diff line number Diff line change
@@ -24,7 +24,8 @@ import {
LogoutToken,
SessionData,
StartInteractiveLoginOptions,
TokenSet
TokenSet,
User
} from "../types";
import {
ensureNoLeadingSlash,
@@ -1134,6 +1135,55 @@ export class AuthClient {

return [null, connectionTokenSet] as [null, ConnectionTokenSet];
}

private async verifyIdToken(
idToken: string
): Promise<[null, jose.JWTPayload] | [SdkError, null]> {
const [discoveryError, authorizationServerMetadata] =
await this.discoverAuthorizationServerMetadata();
if (discoveryError) {
return [discoveryError, null];
}
if (!authorizationServerMetadata.jwks_uri) {
return [new DiscoveryError("JWKS URI not found in metadata"), null];
}
Comment on lines +1147 to +1149
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there cases where we don't publish a jwks_uri for a tenant? Is this check needed?

if (!authorizationServerMetadata.issuer) {
return [new DiscoveryError("Issuer not found in metadata"), null];
}
Comment on lines +1150 to +1152
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issuer can never be undefined and we don't have this check elsewhere. Is there a reason you've added this explicit check here?


const keyInput = jose.createRemoteJWKSet(
new URL(authorizationServerMetadata.jwks_uri)
);

const ID_TOKEN_SIGNING_ALG = "RS256";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we predefining the supported IDT algo here?


try {
const { payload } = await jose.jwtVerify(idToken, keyInput, {
issuer: authorizationServerMetadata.issuer,
audience: this.clientMetadata.client_id,
algorithms: [ID_TOKEN_SIGNING_ALG]
});
return [null, payload];
} catch (e: any) {
return [
new OAuth2Error({
code: e.code || "ID_TOKEN_VERIFICATION_FAILED",
message: e.message || "ID token verification failed."
}),
Comment on lines +1169 to +1172
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's be consistent with the casing and naming of our error codes. They should be added to src/errors/index.ts.

Also, we may want to consider a custom error object here similar to what we have for AT refresh.

null
];
}
}

public async getUserFromIdToken(
idToken: string
): Promise<[null, User] | [SdkError, null]> {
const [error, claims] = await this.verifyIdToken(idToken);
if (error) {
return [error, null];
}
return [null, filterClaims(claims)];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Developers may have provided their own custom filtering function for the ID token claims. In this case, we would be overriding it with the DEFAULT_ALLOWED_CLAIMS which would be inconsistent.

We might want to consider offering a hook to allow for the filtering to be customized and consistent in other places we verify and store the IDT claims.

Copy link
Member

@frederikprijck frederikprijck Jun 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am wondering what the reason is to not have this go through the same logic (including id token claims filtering) we already have in the callback and getAccessToken?

}
}

const encodeBase64 = (input: string) => {
19 changes: 14 additions & 5 deletions src/server/client.ts
Original file line number Diff line number Diff line change
@@ -417,12 +417,21 @@ export class Auth0Client {
throw error;
}

// update the session with the new token set, if necessary
if (
const sessionRequiresSaving =
options.refresh ||
tokenSet.accessToken !== session.tokenSet.accessToken ||
tokenSet.expiresAt !== session.tokenSet.expiresAt ||
tokenSet.refreshToken !== session.tokenSet.refreshToken
) {
tokenSet.idToken !== session.tokenSet.idToken ||
tokenSet.refreshToken !== session.tokenSet.refreshToken ||
tokenSet.expiresAt !== session.tokenSet.expiresAt;

if (sessionRequiresSaving) {
const [profileError, newProfile] =
await this.authClient.getUserFromIdToken(tokenSet.idToken!);
Comment on lines +428 to +429
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could it be that we don't get an ID token back? I believe we require the openid scope but perhaps we should consider falling back to calling /userinfo here?


if (profileError) {
throw profileError;
}
session.user = newProfile;
await this.saveToSession(
{
...session,
117 changes: 106 additions & 11 deletions src/server/get-access-token.test.ts
Original file line number Diff line number Diff line change
@@ -16,6 +16,16 @@ import {
import { SessionData, TokenSet } from "../types";
import { Auth0Client } from "./client";

// Mock jose.jwtVerify to prevent actual JWT verification during getAccessToken flow
vi.mock("jose", async () => {
const actual = await vi.importActual("jose");
return {
...actual,
jwtVerify: vi.fn(),
createRemoteJWKSet: vi.fn()
};
});
Comment on lines +19 to +27
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's more preferable to mint a valid JWT than to mock the dependency.


// Basic constants for testing
const DEFAULT = {
domain: "https://op.example.com",
@@ -29,13 +39,6 @@ const DEFAULT = {
scope: "openid profile email offline_access"
};

const initialTokenSetBase = {
accessToken: "test-access-token",
refreshToken: "test-refresh-token",
idToken: "test-id-token",
scope: DEFAULT.scope
};

const authClientConfig = {
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
@@ -52,6 +55,27 @@ const refreshedExpiresIn = 3600;
const issuer = DEFAULT.domain;
const audience = DEFAULT.clientId;

const getIdToken = async () =>
await new jose.SignJWT({
sid: DEFAULT.sid,
sub: DEFAULT.sub,
auth_time: Math.floor(Date.now() / 1000),
nonce: "nonce-value" // Example nonce
})
.setProtectedHeader({ alg: DEFAULT.alg })
.setIssuer(issuer)
.setAudience(audience)
.setIssuedAt()
.setExpirationTime("1h")
.sign(keyPair.privateKey);

const initialTokenSetBase = async () => ({
accessToken: "test-access-token",
refreshToken: "test-refresh-token",
idToken: await getIdToken(),
scope: DEFAULT.scope
});

const handlers = [
// OIDC Discovery Endpoint
http.get(`${DEFAULT.domain}/.well-known/openid-configuration`, () => {
@@ -121,11 +145,11 @@ afterAll(() => server.close());
/**
* Creates initial session data for tests.
*/
function createInitialSession(): SessionData {
async function createInitialSession(): Promise<SessionData> {
// Use a VALID (non-expired) initial token
const initialExpiresAt = Math.floor(Date.now() / 1000) + 3600; // Expires in 1 hour
const initialTokenSet: TokenSet = {
...initialTokenSetBase, // Spread the base token set from the new constant
...(await initialTokenSetBase()), // Spread the base token set from the new constant
expiresAt: initialExpiresAt // Add the dynamic expiration time
};
const initialSession: SessionData = {
@@ -141,6 +165,34 @@ describe("Auth0Client - getAccessToken", () => {
let auth0Client: Auth0Client;

beforeEach(async () => {
// Clear all mocks before each test
vi.clearAllMocks();
server.resetHandlers();

// Set up jose.jwtVerify mock to prevent actual JWT verification
vi.mocked(jose.jwtVerify).mockResolvedValue({
payload: {
sub: DEFAULT.sub,
sid: DEFAULT.sid,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
aud: DEFAULT.clientId,
iss: DEFAULT.domain
},
protectedHeader: {
alg: "RS256"
},
key: {} as any
} as any);

// Mock createRemoteJWKSet to return a proper key lookup function
vi.mocked(jose.createRemoteJWKSet).mockReturnValue(
vi.fn().mockResolvedValue({
type: "public",
alg: "RS256"
}) as any
);

// Instantiate Auth0Client normally, it will use intercepted fetch
auth0Client = new Auth0Client(authClientConfig);

@@ -160,7 +212,7 @@ describe("Auth0Client - getAccessToken", () => {
* it refreshes the token.
*/
it("should refresh token and save session for pages-router overload when refresh is true (with valid token)", async () => {
const initialSession = createInitialSession();
const initialSession = await createInitialSession();

// Mock getSession specifically for this test
vi.spyOn(Auth0Client.prototype as any, "getSession").mockResolvedValue(
@@ -188,6 +240,9 @@ describe("Auth0Client - getAccessToken", () => {
// The '0' precision checks for equality at the integer second level.
expect(result?.expiresAt).toBeCloseTo(expectedExpiresAtRough, 0);
expect(mockSaveToSession).toHaveBeenCalledOnce();

// Verify that jose.jwtVerify was called (proving our mock is working)
expect(vi.mocked(jose.jwtVerify)).toHaveBeenCalled();
});

/**
@@ -196,7 +251,7 @@ describe("Auth0Client - getAccessToken", () => {
* it refreshes the token.
*/
it("should refresh token for app-router overload when refresh is true (with valid token)", async () => {
const initialSession = createInitialSession();
const initialSession = await createInitialSession();

// Mock getSession specifically for this test
vi.spyOn(Auth0Client.prototype as any, "getSession").mockResolvedValue(
@@ -218,4 +273,44 @@ describe("Auth0Client - getAccessToken", () => {
expect(result?.expiresAt).toBeCloseTo(expectedExpiresAtRough, 0);
expect(mockSaveToSession).toHaveBeenCalledOnce();
});

it("should update session.user with new profile data from refreshed ID token", async () => {
// Initial session with stale user data
const initialSession = await createInitialSession();
initialSession.user = {
sub: DEFAULT.sub,
email_verified: false,
name: "Old Name"
};

// Mock new ID token with updated user claims
const updatedUserClaims = {
sub: DEFAULT.sub,
sid: DEFAULT.sid,
email_verified: true, // Updated
name: "Updated Name", // Updated
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
aud: DEFAULT.clientId,
iss: DEFAULT.domain
};

vi.mocked(jose.jwtVerify).mockResolvedValueOnce({
payload: updatedUserClaims,
protectedHeader: { alg: "RS256" },
key: {} as any
} as any);

vi.spyOn(Auth0Client.prototype as any, "getSession").mockResolvedValue(
initialSession
);

// Execute token refresh
await auth0Client.getAccessToken({ refresh: true });

// Verify user profile data is updated in saved session
const savedSessionData = mockSaveToSession.mock.calls[0][0] as SessionData;
expect(savedSessionData.user.email_verified).toBe(true);
expect(savedSessionData.user.name).toBe("Updated Name");
});
});