Skip to content

Commit 404f201

Browse files
fix: verify auth_time against per-request max_age (#1792)
### Changes `max_age` passed per call (e.g. `loginWithRedirect({ authorizationParams: { max_age: 0 } })`) was sent to `/authorize` but not used when verifying the returned ID token. Only the `max_age` set on the client was checked, so `auth_time` validation was skipped for per-request `max_age`. - `_prepareAuthorizeUrl` now returns the effective `max_age`; it is stored in the login transaction (redirect) or kept in memory (popup, silent iframe) and passed to `_verifyIdToken`, falling back to the client-level value. - `parseNumber('0')` now returns `0` instead of `undefined`. - `verify()` now requires `auth_time` when `max_age` is `0`. ### References [OIDC Core §3.1.3.7 — ID Token Validation](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation), item 13 (`auth_time` must be checked when `max_age` is requested). ### Testing Added unit tests for per-request `max_age` in `loginWithRedirect`, `loginWithPopup` and `getTokenSilently`, plus `max_age: 0` handling in `jwt.verify` and `parseNumber`. The full unit suite passes. - [x] This change adds unit test coverage - [ ] This change adds integration test coverage - [x] This change has been tested on the latest version of the platform/language ### Checklist - [x] I have read the [Auth0 general contribution guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md) - [x] I have read the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md) - [x] All code quality tools/guidelines have been run/followed 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * `max_age` settings are now applied consistently during popup, redirect, and silent sign-ins. Values supplied for a specific sign-in take precedence over the client’s configured value. * A `max_age` of `0` is now preserved and enforced during ID token verification, including the required `auth_time` claim check. * Numeric string values for `max_age`, including `"0"`, are now correctly recognized as numbers. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude <noreply@anthropic.com>
1 parent d4a3349 commit 404f201

9 files changed

Lines changed: 124 additions & 11 deletions

File tree

‎__tests__/Auth0Client/getTokenSilently.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2242,6 +2242,26 @@ describe('Auth0Client', () => {
22422242
);
22432243
});
22442244

2245+
it('calls `tokenVerifier.verify` with the `max_age` given in the method', async () => {
2246+
const auth0 = setup();
2247+
2248+
jest.spyOn(<any>utils, 'runIframe').mockResolvedValue({
2249+
access_token: TEST_ACCESS_TOKEN,
2250+
state: TEST_STATE
2251+
});
2252+
2253+
await getTokenSilently(auth0, {
2254+
authorizationParams: { max_age: 0 },
2255+
cacheMode: 'off'
2256+
});
2257+
2258+
expect(tokenVerifier).toHaveBeenCalledWith(
2259+
expect.objectContaining({
2260+
max_age: 0
2261+
})
2262+
);
2263+
});
2264+
22452265
it('throws error if state from popup response is different from the provided state', async () => {
22462266
const auth0 = setup();
22472267

‎__tests__/Auth0Client/loginWithPopup.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,32 @@ describe('Auth0Client', () => {
566566
);
567567
});
568568

569+
it('calls `tokenVerifier.verify` with the `max_age` given in the login method', async () => {
570+
const auth0 = setup({ authorizationParams: { max_age: 3600 } });
571+
572+
await loginWithPopup(auth0, {
573+
authorizationParams: { max_age: 0 }
574+
});
575+
576+
expect(tokenVerifier).toHaveBeenCalledWith(
577+
expect.objectContaining({
578+
max_age: 0
579+
})
580+
);
581+
});
582+
583+
it('calls `tokenVerifier.verify` with the parsed `max_age` string "0" from constructor', async () => {
584+
const auth0 = setup({ authorizationParams: { max_age: '0' } });
585+
586+
await loginWithPopup(auth0);
587+
588+
expect(tokenVerifier).toHaveBeenCalledWith(
589+
expect.objectContaining({
590+
max_age: 0
591+
})
592+
);
593+
});
594+
569595
it('calls `tokenVerifier.verify` with the organization', async () => {
570596
const auth0 = setup({
571597
authorizationParams: { organization: 'org_123' }

‎__tests__/Auth0Client/loginWithRedirect.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,34 @@ describe('Auth0Client', () => {
528528
);
529529
});
530530

531+
it('calls `tokenVerifier.verify` with the `max_age` given in the login method', async () => {
532+
const auth0 = setup();
533+
534+
await loginWithRedirect(auth0, {
535+
authorizationParams: { max_age: 0 }
536+
});
537+
538+
expect(tokenVerifier).toHaveBeenCalledWith(
539+
expect.objectContaining({
540+
max_age: 0
541+
})
542+
);
543+
});
544+
545+
it('calls `tokenVerifier.verify` with the `max_age` given in the login method over the one from constructor', async () => {
546+
const auth0 = setup({ authorizationParams: { max_age: 3600 } });
547+
548+
await loginWithRedirect(auth0, {
549+
authorizationParams: { max_age: '10' }
550+
});
551+
552+
expect(tokenVerifier).toHaveBeenCalledWith(
553+
expect.objectContaining({
554+
max_age: 10
555+
})
556+
);
557+
});
558+
531559
it('calls `tokenVerifier.verify` with the global organization', async () => {
532560
const auth0 = setup({
533561
authorizationParams: { organization: 'org_123' }

‎__tests__/jwt.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,14 @@ describe('jwt', () => {
370370
);
371371
});
372372

373+
it('validate auth_time is present when max_age is 0', async () => {
374+
const id_token = await createJWT({ ...DEFAULT_PAYLOAD });
375+
376+
expect(() => verify({ ...verifyOptions, id_token, max_age: 0 })).toThrow(
377+
'Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified'
378+
);
379+
});
380+
373381
it('validate auth_time + max_age is in the future', async () => {
374382
const yesterday = new Date();
375383
yesterday.setDate(yesterday.getDate() - 1);

‎__tests__/utils.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getCrypto,
1313
validateCrypto,
1414
fromEntries,
15+
parseNumber,
1516
stripAuth0Client
1617
} from '../src/utils';
1718

@@ -560,6 +561,23 @@ describe('utils', () => {
560561
});
561562
});
562563

564+
describe('parseNumber', () => {
565+
it('parses numeric strings, including "0"', () => {
566+
expect(parseNumber('10')).toBe(10);
567+
expect(parseNumber('0')).toBe(0);
568+
});
569+
570+
it('returns undefined for non-numeric strings', () => {
571+
expect(parseNumber('')).toBeUndefined();
572+
expect(parseNumber('abc')).toBeUndefined();
573+
});
574+
575+
it('returns non-string values as-is', () => {
576+
expect(parseNumber(0)).toBe(0);
577+
expect(parseNumber(undefined)).toBeUndefined();
578+
});
579+
});
580+
563581
describe('fromEntries', () => {
564582
const data = { a: 'b', c: 'd' };
565583
const iterable = new Headers(data);

‎src/Auth0Client.ts‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,8 @@ export class Auth0Client {
462462
private async _verifyIdToken(
463463
id_token: string,
464464
nonce?: string,
465-
organization?: string
465+
organization?: string,
466+
max_age?: string | number
466467
) {
467468
const now = await this.nowProvider();
468469

@@ -473,7 +474,9 @@ export class Auth0Client {
473474
nonce,
474475
organization,
475476
leeway: this.options.leeway,
476-
max_age: parseNumber(this.options.authorizationParams.max_age),
477+
max_age: parseNumber(
478+
max_age ?? this.options.authorizationParams.max_age
479+
),
477480
now
478481
});
479482
}
@@ -561,6 +564,7 @@ export class Auth0Client {
561564
code_verifier: string;
562565
state: string;
563566
url: string;
567+
max_age?: string | number;
564568
}> {
565569
const state = encode(createRandomString());
566570
const nonce = encode(createRandomString());
@@ -592,7 +596,8 @@ export class Auth0Client {
592596
audience: params.audience || DEFAULT_AUDIENCE,
593597
redirect_uri: params.redirect_uri,
594598
state,
595-
url
599+
url,
600+
max_age: params.max_age
596601
};
597602
}
598603

@@ -678,7 +683,8 @@ export class Auth0Client {
678683
},
679684
{
680685
nonceIn: params.nonce,
681-
organization
686+
organization,
687+
max_age: params.max_age
682688
}
683689
);
684690
}
@@ -862,7 +868,7 @@ export class Auth0Client {
862868
code: code as string,
863869
...(redirect_uri ? { redirect_uri } : {})
864870
},
865-
{ nonceIn, organization }
871+
{ nonceIn, organization, max_age: transaction.max_age }
866872
);
867873

868874
return {
@@ -1490,7 +1496,8 @@ export class Auth0Client {
14901496
code_verifier,
14911497
redirect_uri,
14921498
scope,
1493-
audience
1499+
audience,
1500+
max_age
14941501
} = await this._prepareAuthorizeUrl(
14951502
params,
14961503
{ response_mode: 'web_message' },
@@ -1539,7 +1546,8 @@ export class Auth0Client {
15391546
},
15401547
{
15411548
nonceIn,
1542-
organization: params.organization
1549+
organization: params.organization,
1550+
max_age
15431551
}
15441552
);
15451553

@@ -1898,7 +1906,8 @@ export class Auth0Client {
18981906
| WebauthnRequestTokenOptions,
18991907
additionalParameters?: RequestTokenAdditionalParameters
19001908
) {
1901-
const { nonceIn, organization, scopesToRequest } = additionalParameters || {};
1909+
const { nonceIn, organization, scopesToRequest, max_age } =
1910+
additionalParameters || {};
19021911
try {
19031912
const authResult = await oauthToken(
19041913
{
@@ -1919,7 +1928,8 @@ export class Auth0Client {
19191928
let decodedToken = await this._verifyIdToken(
19201929
authResult.id_token,
19211930
nonceIn,
1922-
organization
1931+
organization,
1932+
max_age
19231933
);
19241934

19251935
// When logging in with authorization_code, check if a different user is authenticating
@@ -2478,4 +2488,5 @@ interface RequestTokenAdditionalParameters {
24782488
nonceIn?: string;
24792489
organization?: string;
24802490
scopesToRequest?: string;
2491+
max_age?: string | number;
24812492
}

‎src/jwt.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ export const verify = (options: JWTVerifyOptions) => {
140140
}
141141
}
142142

143-
if (options.max_age && !isNumber(decoded.claims.auth_time)) {
143+
if (options.max_age != null && !isNumber(decoded.claims.auth_time)) {
144144
throw new Error(
145145
'Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified'
146146
);

‎src/transaction-manager.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface LoginTransaction {
1111
redirect_uri?: string;
1212
organization?: string;
1313
state?: string;
14+
max_age?: string | number;
1415
response_type: 'code';
1516
}
1617

‎src/utils.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,8 @@ export const parseNumber = (value: any): number | undefined => {
297297
if (typeof value !== 'string') {
298298
return value;
299299
}
300-
return parseInt(value, 10) || undefined;
300+
const parsed = parseInt(value, 10);
301+
return isNaN(parsed) ? undefined : parsed;
301302
};
302303

303304
/**

0 commit comments

Comments
 (0)