-
Notifications
You must be signed in to change notification settings - Fork 422
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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]; | ||
} | ||
if (!authorizationServerMetadata.issuer) { | ||
return [new DiscoveryError("Issuer not found in metadata"), null]; | ||
} | ||
Comment on lines
+1150
to
+1152
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
|
||
const keyInput = jose.createRemoteJWKSet( | ||
new URL(authorizationServerMetadata.jwks_uri) | ||
); | ||
|
||
const ID_TOKEN_SIGNING_ALG = "RS256"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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)]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) => { | ||
|
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
||
if (profileError) { | ||
throw profileError; | ||
} | ||
session.user = newProfile; | ||
await this.saveToSession( | ||
{ | ||
...session, | ||
|
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"); | ||
}); | ||
}); |
There was a problem hiding this comment.
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?