Skip to content

Commit 4583411

Browse files
committed
fix(sessionTransferToken): align with auth0-server-js — id_token expiry check, empty issued_token_type fallback, extra params over reason
1 parent 9813acb commit 4583411

4 files changed

Lines changed: 121 additions & 27 deletions

File tree

index.d.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -258,10 +258,11 @@ interface SessionTransferTokenOptions {
258258
/** Scopes for the established session's tokens. */
259259
scope?: string;
260260
/**
261-
* Optional reason for the exchange. Included in the `act` claim when your CTE Action
262-
* passes it to `setActor(...)`.
261+
* Additional parameters forwarded to the token endpoint and available in your CTE Action
262+
* via `event.request.body` (e.g. `{ reason: 'Investigating TCK-1234' }`).
263+
* Cannot override reserved OAuth parameters.
263264
*/
264-
reason?: string;
265+
extra?: Record<string, string | string[] | number | boolean>;
265266
}
266267

267268
/**
@@ -376,7 +377,7 @@ interface RequestContext {
376377
* const result = await req.oidc.requestSessionTransferToken({
377378
* subject_token: req.body.customerToken,
378379
* subject_token_type: 'urn:mycompany:customer-subject',
379-
* reason: 'Investigating ticket TCK-1234',
380+
* extra: { reason: 'Investigating ticket TCK-1234' },
380381
* });
381382
* res.redirect(req.oidc.buildSessionTransferRedirect('https://app.example.com/login', result));
382383
* });

lib/context.js

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const {
3737
extractSessionExpiry,
3838
isSessionExpiryReached,
3939
isSessionExpiryInPast,
40+
isIdTokenExpired,
4041
} = require('./utils/sessionExpiry');
4142

4243
// Caches one RemoteJWKSet instance per auth() configuration for backchannel logout token verification.
@@ -456,7 +457,7 @@ class RequestContext {
456457
actor_token_type,
457458
organization,
458459
scope,
459-
reason,
460+
extra,
460461
} = options;
461462

462463
validateSubjectToken(subject_token);
@@ -475,7 +476,6 @@ class RequestContext {
475476
resolvedActorTokenType = actor_token_type || ID_TOKEN_IDENTIFIER;
476477
} else {
477478
// Source actor from the current session id_token.
478-
// If the token set is expired and there's a refresh token, refresh first.
479479
const ts = tokenSet.call(this);
480480

481481
if (!ts) {
@@ -486,7 +486,24 @@ class RequestContext {
486486
);
487487
}
488488

489-
if (ts.expired() && ts.refresh_token) {
489+
const idToken = this.idToken;
490+
if (!idToken) {
491+
throw createError(
492+
400,
493+
'No actor available: session exists but contains no id_token. Ensure the session was established with the openid scope, or pass an explicit actor_token.',
494+
{ error: 'actor_unavailable' },
495+
);
496+
}
497+
498+
const { exp } = ts.claims();
499+
if (isIdTokenExpired(exp)) {
500+
if (!ts.refresh_token) {
501+
throw createError(
502+
400,
503+
'No actor available: id_token has expired and no refresh token is available.',
504+
{ error: 'actor_unavailable' },
505+
);
506+
}
490507
debug(
491508
'requestSessionTransferToken: id_token expired, refreshing session',
492509
);
@@ -501,14 +518,7 @@ class RequestContext {
501518
}
502519
}
503520

504-
const idToken = this.idToken;
505-
if (!idToken) {
506-
throw createError(400, 'No actor available: no id_token in session.', {
507-
error: 'actor_unavailable',
508-
});
509-
}
510-
511-
resolvedActorToken = idToken;
521+
resolvedActorToken = this.idToken;
512522
resolvedActorTokenType = ID_TOKEN_IDENTIFIER;
513523
}
514524

@@ -532,7 +542,7 @@ class RequestContext {
532542
audience,
533543
...(scope !== undefined && { scope }),
534544
...(organization !== undefined && { organization }),
535-
...(reason !== undefined && { reason }),
545+
...validateTokenExchangeExtras(extra),
536546
};
537547

538548
try {
@@ -544,8 +554,7 @@ class RequestContext {
544554

545555
return {
546556
session_transfer_token: exchanged.access_token,
547-
issued_token_type:
548-
exchanged.issued_token_type || SESSION_TRANSFER_TOKEN_IDENTIFIER,
557+
issued_token_type: exchanged.issued_token_type || '',
549558
expires_in: exchanged.expires_in,
550559
token_type: exchanged.token_type,
551560
...(exchanged.scope !== undefined && { scope: exchanged.scope }),

lib/utils/sessionExpiry.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,19 @@ function isSessionExpiryInPast(sessionExpiresAt, issuedAt) {
5050
return isSessionExpiryReached(sessionExpiresAt, reference);
5151
}
5252

53+
/**
54+
* Returns whether an ID token's `exp` claim is expired, applying a forward skew so a token
55+
* about to expire is treated as already expired and refreshed before being sent as an actor token.
56+
* @param {number} exp - the `exp` claim from the decoded ID token
57+
*/
58+
function isIdTokenExpired(exp) {
59+
return exp <= epoch() + SESSION_EXPIRY_LEEWAY;
60+
}
61+
5362
module.exports = {
5463
SESSION_EXPIRY_LEEWAY,
5564
extractSessionExpiry,
5665
isSessionExpiryReached,
5766
isSessionExpiryInPast,
67+
isIdTokenExpired,
5868
};

test/sessionTransferToken.tests.js

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,84 @@ describe('requestSessionTransferToken', () => {
176176
assert.equal(response.body.err.error, 'actor_unavailable');
177177
});
178178

179+
it('throws 400 with actor_unavailable when id_token is expired and no refresh_token', async () => {
180+
const expiredIdToken = makeIdToken({
181+
exp: Math.floor(Date.now() / 1000) - 3600,
182+
});
183+
const { response } = await setup({
184+
sttOptions: {
185+
subject_token: '__test_subject__',
186+
subject_token_type: 'urn:mycompany:test-token',
187+
},
188+
sessionData: {
189+
id_token: expiredIdToken,
190+
access_token: '__test_access_token__',
191+
token_type: 'Bearer',
192+
expires_at: Math.floor(Date.now() / 1000) - 3600,
193+
},
194+
mockTokenResponse: false,
195+
});
196+
assert.equal(response.statusCode, 400);
197+
assert.equal(response.body.err.error, 'actor_unavailable');
198+
});
199+
200+
it('refreshes expired id_token and uses the fresh one as actor when refresh_token is available', async () => {
201+
const expiredIdToken = makeIdToken({
202+
exp: Math.floor(Date.now() / 1000) - 3600,
203+
});
204+
const freshIdToken = makeIdToken();
205+
206+
const router = express.Router();
207+
router.use(auth({ ...defaultConfig }));
208+
router.get('/stt', async (req, res, next) => {
209+
try {
210+
const result = await req.oidc.requestSessionTransferToken({
211+
subject_token: '__test_subject__',
212+
subject_token_type: 'urn:mycompany:test-token',
213+
});
214+
res.json(result);
215+
} catch (err) {
216+
next(err);
217+
}
218+
});
219+
220+
server = await createServer(router);
221+
const jar = request.jar();
222+
await request.post('/session', {
223+
baseUrl,
224+
jar,
225+
json: {
226+
id_token: expiredIdToken,
227+
access_token: '__test_access_token__',
228+
refresh_token: '__test_refresh_token__',
229+
token_type: 'Bearer',
230+
expires_at: Math.floor(Date.now() / 1000) - 3600,
231+
},
232+
});
233+
234+
let capturedSttBody;
235+
// First call: refresh grant; second call: STT exchange
236+
nock('https://op.example.com')
237+
.post('/oauth/token')
238+
.reply(200, {
239+
access_token: '__new_access_token__',
240+
id_token: freshIdToken,
241+
refresh_token: '__new_refresh_token__',
242+
token_type: 'Bearer',
243+
expires_in: 86400,
244+
})
245+
.post('/oauth/token')
246+
.reply(200, function (uri, body) {
247+
capturedSttBody = qs.parse(body);
248+
return defaultSTTResponse();
249+
});
250+
251+
const response = await request.get('/stt', { baseUrl, jar, json: true });
252+
assert.equal(response.statusCode, 200);
253+
assert.equal(capturedSttBody.actor_token, freshIdToken);
254+
assert.equal(capturedSttBody.actor_token_type, ID_TOKEN_IDENTIFIER);
255+
});
256+
179257
// -------------------------------------------------------------------------
180258
// subject_token validation (reuses validateSubjectToken)
181259
// -------------------------------------------------------------------------
@@ -268,18 +346,18 @@ describe('requestSessionTransferToken', () => {
268346
assert.equal(capturedBody.organization, 'org_abc123');
269347
});
270348

271-
it('sends reason when provided', async () => {
349+
it('forwards extra params to the token endpoint', async () => {
272350
const { capturedBody } = await setup({
273351
sttOptions: {
274352
subject_token: '__test_subject__',
275353
subject_token_type: 'urn:mycompany:test-token',
276-
reason: 'Investigating TCK-4821',
354+
extra: { reason: 'Investigating TCK-4821' },
277355
},
278356
});
279357
assert.equal(capturedBody.reason, 'Investigating TCK-4821');
280358
});
281359

282-
it('omits scope, organization, and reason when not provided', async () => {
360+
it('omits scope, organization, and extra when not provided', async () => {
283361
const { capturedBody } = await setup({
284362
sttOptions: {
285363
subject_token: '__test_subject__',
@@ -288,7 +366,6 @@ describe('requestSessionTransferToken', () => {
288366
});
289367
assert.isUndefined(capturedBody.scope);
290368
assert.isUndefined(capturedBody.organization);
291-
assert.isUndefined(capturedBody.reason);
292369
});
293370

294371
// -------------------------------------------------------------------------
@@ -319,7 +396,7 @@ describe('requestSessionTransferToken', () => {
319396
);
320397
});
321398

322-
it('falls back to SESSION_TRANSFER_TOKEN_IDENTIFIER when issued_token_type is absent', async () => {
399+
it('returns empty string for issued_token_type when absent in AS response', async () => {
323400
const { response } = await setup({
324401
sttOptions: {
325402
subject_token: '__test_subject__',
@@ -336,10 +413,7 @@ describe('requestSessionTransferToken', () => {
336413
},
337414
});
338415
assert.equal(response.statusCode, 200);
339-
assert.equal(
340-
response.body.issued_token_type,
341-
SESSION_TRANSFER_TOKEN_IDENTIFIER,
342-
);
416+
assert.equal(response.body.issued_token_type, '');
343417
});
344418

345419
it('result has no access_token field — STT is in session_transfer_token', async () => {

0 commit comments

Comments
 (0)