Skip to content

Commit e526298

Browse files
authored
Adding RFC9068 compliance button and generate sample JWT that is RFC9068 compliant button. Plus, associated tests. (#177) (#178)
1 parent c6216aa commit e526298

3 files changed

Lines changed: 163 additions & 1 deletion

File tree

client/public/jwt_tools.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,12 @@
7171
</div>
7272

7373
<div class="jt-row">
74-
<input type="submit" value="Check RFC Compliance" onclick="return jwt_tools.checkCompliance();" />
74+
<input type="submit" value="Generate RFC 9068 Token" onclick="return jwt_tools.generateRfc9068Token();" title="Populate the Header, Payload, and Encoded JWT with a sample RFC 9068 access token (unsigned)." />
75+
</div>
76+
77+
<div class="jt-row">
78+
<input type="submit" value="JWT RFC Compliance" onclick="return jwt_tools.checkCompliance();" title="Validate against the JWT specs (RFC 7519 / RFC 7515)." />
79+
<input type="submit" value="RFC 9068 Compliance" onclick="return jwt_tools.checkRfc9068Compliance();" title="Validate as an OAuth 2.0 JWT access token (RFC 9068)." />
7580
</div>
7681
<div class="jt-field">
7782
<label>Compliance Output</label>

client/src/jwt_tools.js

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,140 @@ function checkCompliance() {
316316
return false;
317317
}
318318

319+
// Validate the composed header/payload as an OAuth 2.0 JWT access token per
320+
// RFC 9068 (JWT Profile for OAuth 2.0 Access Tokens). Output goes to the same
321+
// Compliance Output box. Header (§2.1): typ MUST be "at+jwt" and the token MUST
322+
// be signed (alg present, not "none"). Required claims (§2.2): iss, exp, aud,
323+
// sub, client_id, iat, jti. scope is conditionally recommended (§2.2.3);
324+
// auth_time/acr/amr are optional (§2.2.1) and only type-checked if present.
325+
function checkRfc9068Compliance() {
326+
var results = [];
327+
function pass(c, m) { results.push('PASS ' + c + ': ' + m); }
328+
function fail(c, m) { results.push('FAIL ' + c + ': ' + m); }
329+
function skip(c, m) { results.push('SKIP ' + c + ': ' + m); }
330+
331+
var header, payload;
332+
try {
333+
header = parseJson('jwt_tools_header', 'JWT Header');
334+
} catch (e) {
335+
setVal('compliance_output', 'FAIL header: ' + e.message);
336+
return false;
337+
}
338+
try {
339+
payload = parseJson('jwt_tools_payload', 'JWT Payload');
340+
} catch (e) {
341+
setVal('compliance_output', 'FAIL payload: ' + e.message);
342+
return false;
343+
}
344+
345+
results.push('RFC 9068 — OAuth 2.0 JWT Access Token');
346+
347+
// ---- Header (RFC 9068 §2.1) ----
348+
// typ is REQUIRED and MUST be "at+jwt" (the "application/" prefix is allowed).
349+
if (header.typ === undefined) {
350+
fail('typ', 'Missing — MUST be "at+jwt" (RFC 9068 §2.1)');
351+
} else if (typeof header.typ !== 'string') {
352+
fail('typ', '"typ" must be a string');
353+
} else if (header.typ === 'at+jwt' || header.typ === 'application/at+jwt') {
354+
pass('typ', '"' + header.typ + '"');
355+
} else {
356+
fail('typ', '"' + header.typ + '" — MUST be "at+jwt" (RFC 9068 §2.1)');
357+
}
358+
359+
// The token MUST be signed; alg is REQUIRED and MUST NOT be "none".
360+
if (!header.alg) {
361+
fail('alg', 'Missing — access tokens MUST be signed (RFC 9068 §2.1)');
362+
} else if (typeof header.alg !== 'string') {
363+
fail('alg', '"alg" must be a string');
364+
} else if (header.alg === 'none') {
365+
fail('alg', '"none" is not permitted — access tokens MUST be signed (RFC 9068 §2.1)');
366+
} else {
367+
pass('alg', header.alg);
368+
}
369+
370+
// ---- Required claims (RFC 9068 §2.2) ----
371+
function requireString(name) {
372+
if (payload[name] === undefined) fail(name, 'Missing required claim (RFC 9068 §2.2)');
373+
else if (typeof payload[name] !== 'string') fail(name, 'Must be a string');
374+
else pass(name, '"' + payload[name] + '"');
375+
}
376+
function requireNumericDate(name) {
377+
if (payload[name] === undefined) fail(name, 'Missing required claim (RFC 9068 §2.2)');
378+
else if (typeof payload[name] !== 'number' || !Number.isInteger(payload[name])) fail(name, 'Must be an integer NumericDate');
379+
else pass(name, new Date(payload[name] * 1000).toISOString());
380+
}
381+
382+
requireString('iss');
383+
requireNumericDate('exp');
384+
385+
// aud is REQUIRED: a StringOrURI or a non-empty array of them.
386+
if (payload.aud === undefined) {
387+
fail('aud', 'Missing required claim (RFC 9068 §2.2)');
388+
} else if (typeof payload.aud === 'string') {
389+
pass('aud', '"' + payload.aud + '"');
390+
} else if (Array.isArray(payload.aud) && payload.aud.length > 0 && payload.aud.every(function (a) { return typeof a === 'string'; })) {
391+
pass('aud', payload.aud.length + ' value(s)');
392+
} else {
393+
fail('aud', 'Must be a string or non-empty array of strings');
394+
}
395+
396+
requireString('sub');
397+
requireString('client_id');
398+
requireNumericDate('iat');
399+
requireString('jti');
400+
401+
// ---- Conditional / optional claims ----
402+
// scope SHOULD be present when a scope was requested (RFC 9068 §2.2.3).
403+
if (payload.scope === undefined) {
404+
skip('scope', 'Not present (SHOULD be present if a scope was requested — RFC 9068 §2.2.3)');
405+
} else if (typeof payload.scope !== 'string') {
406+
fail('scope', 'Must be a space-delimited string (RFC 9068 §2.2.3)');
407+
} else {
408+
pass('scope', '"' + payload.scope + '"');
409+
}
410+
411+
// Authentication information claims are optional (RFC 9068 §2.2.1).
412+
if (payload.auth_time !== undefined) {
413+
if (typeof payload.auth_time !== 'number' || !Number.isInteger(payload.auth_time)) fail('auth_time', 'Must be an integer NumericDate');
414+
else pass('auth_time', new Date(payload.auth_time * 1000).toISOString());
415+
}
416+
if (payload.acr !== undefined) {
417+
if (typeof payload.acr !== 'string') fail('acr', 'Must be a string');
418+
else pass('acr', '"' + payload.acr + '"');
419+
}
420+
if (payload.amr !== undefined) {
421+
if (Array.isArray(payload.amr) && payload.amr.every(function (a) { return typeof a === 'string'; })) pass('amr', payload.amr.length + ' value(s)');
422+
else fail('amr', 'Must be an array of strings');
423+
}
424+
425+
setVal('compliance_output', results.join('\n'));
426+
return false;
427+
}
428+
429+
// Populate Header, Payload, and the Encoded JWT with a sample RFC 9068 access
430+
// token: header carries alg + typ "at+jwt"; payload carries the required claims
431+
// (iss, exp, aud, sub, client_id, iat, jti) plus a scope. Produced unsigned
432+
// (header.payload.) — sign it in the Sign pane to complete it.
433+
function generateRfc9068Token() {
434+
var now = Math.floor(Date.now() / 1000);
435+
var header = { alg: 'RS256', typ: 'at+jwt' };
436+
var payload = {
437+
iss: 'https://as.example.com',
438+
sub: 'user-1234',
439+
aud: 'https://api.example.com',
440+
client_id: 'example-client',
441+
iat: now,
442+
exp: now + 3600,
443+
jti: bytesToB64u(crypto.getRandomValues(new Uint8Array(12))),
444+
scope: 'read write'
445+
};
446+
setVal('jwt_tools_header', JSON.stringify(header, null, 2));
447+
setVal('jwt_tools_payload', JSON.stringify(payload, null, 2));
448+
updateEncoded(); // fills the Encoded JWT field (header.payload.) from the above
449+
setVal('jwt_tools_sync_status', 'Generated a sample RFC 9068 access token (unsigned). Sign it in the Sign (JWS) pane to complete it.');
450+
return false;
451+
}
452+
319453
// ---------------------------------------------------------------------------
320454
// Digital signatures (JWS)
321455
// ---------------------------------------------------------------------------
@@ -1042,6 +1176,8 @@ module.exports = {
10421176
onEncodedInput,
10431177
addClaim,
10441178
checkCompliance,
1179+
checkRfc9068Compliance,
1180+
generateRfc9068Token,
10451181
generateSigningKeys,
10461182
signJWT,
10471183
verifyJWT,

tests/jwt_tools.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,27 @@ async function jwtToolsActivities(driver) {
240240
assert.ok(compliance.indexOf("FAIL") === -1, "Compliance output contained a FAIL:\n" + compliance);
241241
log.info("Compliance check passed with no FAIL entries.");
242242

243+
// ---- Pane 1: generate an RFC 9068 access token, then validate it --------
244+
// "Generate RFC 9068 Token" overwrites the Header/Payload/Encoded fields with
245+
// a sample OAuth2 JWT access token; "RFC 9068 Compliance" must then pass.
246+
log.info("Generate RFC 9068 access token.");
247+
await click(driver, onclickBtn("generateRfc9068Token"));
248+
await waitForValue(driver, By.id("jwt_tools_header"),
249+
function (v) { return v.indexOf('"at+jwt"') !== -1; },
250+
"Generate RFC 9068 Token did not populate the header with typ \"at+jwt\".");
251+
252+
log.info("Check RFC 9068 compliance.");
253+
await click(driver, onclickBtn("checkRfc9068Compliance"));
254+
// Wait for the RFC 9068 output specifically (distinguishes it from the prior
255+
// JWT-RFC output already sitting in the box).
256+
var rfc9068 = await waitForValue(driver, By.id("compliance_output"),
257+
function (v) { return v.indexOf("RFC 9068") !== -1; },
258+
"RFC 9068 compliance output was not produced.");
259+
log.info("RFC 9068 compliance output:\n" + rfc9068);
260+
assert.ok(rfc9068.indexOf("PASS") !== -1, "Expected at least one PASS in RFC 9068 output.");
261+
assert.ok(rfc9068.indexOf("FAIL") === -1, "RFC 9068 compliance reported a FAIL:\n" + rfc9068);
262+
log.info("RFC 9068 compliance passed with no FAIL entries.");
263+
243264
// ---- Pane 2: signing (JWS) ----------------------------------------------
244265
log.info("Generate signing keys.");
245266
await click(driver, onclickBtn("generateSigningKeys"));

0 commit comments

Comments
 (0)