diff --git a/std/jwt/README.md b/std/jwt/README.md new file mode 100644 index 0000000000..95d849d527 --- /dev/null +++ b/std/jwt/README.md @@ -0,0 +1,90 @@ +# jwt + +Create and verify JSON Web Tokens. + +## JSON Web Token + +### create + +Takes a `payload`, `key` and `header` and returns the url-safe encoded `token`. + +```typescript +import { create } from "https://deno.land/std/token/mod.ts"; + +const payload = { foo: "bar" }; +const key = "secret"; + +const token = await create(payload, key); // eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.4i-Q1Y0oDZunLgaorkqbYNcNfn5CgdF49UvJ7dUQ4GVTQvpsMLHABkZBWp9sghy3qVOsec6hOcu4RnbFkS30zQ +``` + +**Specific algorithm** + +```typescript +const token = await create(payload, key, { header: { alg: "HS256" } }); +``` + +### verify + +Takes a `token`, `key` and an optional `options` object and returns the +`payload` of the `token` if the `token` is valid. Otherwise it throws an +`Error`. + +```typescript +import { verify } from "https://deno.land/std/token/mod.ts"; + +const token = + "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.4i-Q1Y0oDZunLgaorkqbYNcNfn5CgdF49UvJ7dUQ4GVTQvpsMLHABkZBWp9sghy3qVOsec6hOcu4RnbFkS30zQ"; +const key = "secret"; + +const payload = await verify(token, key); // { foo: "bar" } +``` + +**Specific algorithm** + +```ts +const payload = await verify(token, key, { algorithm: "HS256" }); +``` + +### decode + +Takes a `token` to return an object with the `header`, `payload` and `signature` +properties if the `token` is valid. Otherwise it throws an `Error`. + +```typescript +import { decode } from "https://deno.land/std/token/mod.ts"; + +const token = + "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.4i-Q1Y0oDZunLgaorkqbYNcNfn5CgdF49UvJ7dUQ4GVTQvpsMLHABkZBWp9sghy3qVOsec6hOcu4RnbFkS30zQ"; + +const { payload, signature, header } = await decode(token); // { header: { alg: "HS512", typ: "JWT" }, payload: { foo: "bar" }, signature: "e22f90d58d280d9ba72e06a8ae4a9b60d70d7e7e4281d178f54bc9edd510e0655342fa6c30b1c00646415a9f6c821cb7a953ac79cea139cbb84676c5912df4cd" } +``` + +## Expiration + +The optional **exp** claim in the payload (number of seconds since January 1, +1970, 00:00:00 UTC) that identifies the expiration time on or after which the +JWT must not be accepted for processing. This module checks if the current +date/time is before the expiration date/time listed in the **exp** claim. + +```typescript +const oneHour = 60 * 60; +const token = await create({ exp: Date.now() + oneHour }, "secret"); +``` + +## Algorithms + +The following signature and MAC algorithms have been implemented: + +- HS256 (HMAC SHA-256) +- HS512 (HMAC SHA-512) +- none ([_Unsecured JWTs_](https://tools.ietf.org/html/rfc7519#section-6)). + +## Serialization + +This application uses the JWS Compact Serialization only. + +## Specifications + +- [JSON Web Token](https://tools.ietf.org/html/rfc7519) +- [JSON Web Signature](https://www.rfc-editor.org/rfc/rfc7515.html) +- [JSON Web Algorithms](https://www.rfc-editor.org/rfc/rfc7518.html) diff --git a/std/jwt/_algorithm.ts b/std/jwt/_algorithm.ts new file mode 100644 index 0000000000..c9c5257e10 --- /dev/null +++ b/std/jwt/_algorithm.ts @@ -0,0 +1,17 @@ +/* + * JSW §1: Cryptographic algorithms and identifiers for use with this specification + * are described in the separate JSON Web Algorithms (JWA) specification: + * https://www.rfc-editor.org/rfc/rfc7518 + */ +export type Algorithm = "none" | "HS256" | "HS512"; +export type AlgorithmInput = Algorithm | Array>; +/** + * Verify the algorithm + * @param algorithm as string or multiple algorithms in an array excluding 'none' + * @param the algorithm from the jwt header + */ +export function verify(algorithm: AlgorithmInput, jwtAlg: string): boolean { + return Array.isArray(algorithm) + ? (algorithm as string[]).includes(jwtAlg) + : algorithm === jwtAlg; +} diff --git a/std/jwt/_algorithm_test.ts b/std/jwt/_algorithm_test.ts new file mode 100644 index 0000000000..99583bd79c --- /dev/null +++ b/std/jwt/_algorithm_test.ts @@ -0,0 +1,11 @@ +import { assertEquals } from "../testing/asserts.ts"; + +import { verify as verifyAlgorithm } from "./_algorithm.ts"; + +Deno.test("[jwt] verify algorithm", function () { + assertEquals(verifyAlgorithm("HS512", "HS512"), true); + assertEquals(verifyAlgorithm("HS512", "HS256"), false); + assertEquals(verifyAlgorithm(["HS512"], "HS512"), true); + assertEquals(verifyAlgorithm(["HS256", "HS512"], "HS512"), true); + assertEquals(verifyAlgorithm(["HS512"], "HS256"), false); +}); diff --git a/std/jwt/_signature.ts b/std/jwt/_signature.ts new file mode 100644 index 0000000000..81c1309d14 --- /dev/null +++ b/std/jwt/_signature.ts @@ -0,0 +1,63 @@ +import type { Algorithm } from "./_algorithm.ts"; +import { HmacSha256 } from "../hash/sha256.ts"; +import { HmacSha512 } from "../hash/sha512.ts"; +import { encode as convertUint8ArrayToBase64url } from "../encoding/base64url.ts"; +import { decodeString as convertHexToUint8Array } from "../encoding/hex.ts"; + +export function convertHexToBase64url(input: string): string { + return convertUint8ArrayToBase64url(convertHexToUint8Array(input)); +} + +function encrypt( + algorithm: Algorithm, + key: string, + message: string, +): string { + switch (algorithm) { + case "none": + return ""; + case "HS256": + return new HmacSha256(key).update(message).toString(); + case "HS512": + return new HmacSha512(key).update(message).toString(); + default: + throw new RangeError( + `The algorithm of '${algorithm}' in the header is not supported.`, + ); + } +} + +/** + * Create a signature + * @param algorithm + * @param key + * @param input + */ +export async function create( + algorithm: Algorithm, + key: string, + input: string, +): Promise { + return convertHexToBase64url(await encrypt(algorithm, key, input)); +} + +/** + * Verify a signature + * @param signature + * @param key + * @param alg + * @param signingInput + */ +export async function verify({ + signature, + key, + algorithm, + signingInput, +}: { + signature: string; + key: string; + algorithm: Algorithm; + signingInput: string; +}): Promise { + return signature === (await encrypt(algorithm, key, signingInput)); +} diff --git a/std/jwt/_signature_test.ts b/std/jwt/_signature_test.ts new file mode 100644 index 0000000000..c02f0f4bc3 --- /dev/null +++ b/std/jwt/_signature_test.ts @@ -0,0 +1,46 @@ +import { assertEquals } from "../testing/asserts.ts"; +import { create, decode } from "./mod.ts"; + +import { + convertHexToBase64url, + create as createSignature, + verify as verifySignature, +} from "./_signature.ts"; + +const algorithm = "HS256"; +const key = "m$y-key"; + +Deno.test("[jwt] create signature", async function () { + // https://www.freeformatter.com/hmac-generator.html + const computedHmacInHex = + "2b9e6619fa7f2c8d8b3565c88365376b75b1b0e5d87e41218066fd1986f2c056"; + assertEquals( + await createSignature(algorithm, key, "thisTextWillBeEncrypted"), + convertHexToBase64url(computedHmacInHex), + ); + + const anotherVerifiedSignatureInBase64Url = + "p2KneqJhji8T0PDlVxcG4DROyzTgWXbDhz_mcTVojXo"; + assertEquals( + await createSignature( + algorithm, + key, + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ", + ), + anotherVerifiedSignatureInBase64Url, + ); +}); + +Deno.test("[jwt] verify signature", async function () { + const jwt = await create({}, key); + const { header, signature } = decode(jwt); + + const validSignature = await verifySignature({ + signature, + key, + algorithm: header.alg, + signingInput: jwt.slice(0, jwt.lastIndexOf(".")), + }); + + assertEquals(validSignature, true); +}); diff --git a/std/jwt/mod.ts b/std/jwt/mod.ts new file mode 100644 index 0000000000..09485c8c63 --- /dev/null +++ b/std/jwt/mod.ts @@ -0,0 +1,208 @@ +import type { Algorithm, AlgorithmInput } from "./_algorithm.ts"; +import * as base64url from "../encoding/base64url.ts"; +import { encodeToString as convertUint8ArrayToHex } from "../encoding/hex.ts"; +import { + create as createSignature, + verify as verifySignature, +} from "./_signature.ts"; +import { verify as verifyAlgorithm } from "./_algorithm.ts"; + +/* + * JWT §4.1: The following Claim Names are registered in the IANA + * "JSON Web Token Claims" registry established by Section 10.1. None of the + * claims defined below are intended to be mandatory to use or implement in all + * cases, but rather they provide a starting point for a set of useful, + * interoperable claims. + * Applications using JWTs should define which specific claims they use and when + * they are required or optional. + */ +export interface PayloadObject { + iss?: string; + sub?: string; + aud?: string[] | string; + exp?: number; + nbf?: number; + iat?: number; + jti?: string; + [key: string]: unknown; +} + +export type Payload = PayloadObject | string; + +/* + * JWS §4.1.1: The "alg" value is a case-sensitive ASCII string containing a + * StringOrURI value. This Header Parameter MUST be present and MUST be + * understood and processed by implementations. + */ +export interface Header { + alg: Algorithm; + [key: string]: unknown; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/* + * JWT §4.1.4: Implementers MAY provide for some small leeway to account for + * clock skew. + */ +function isExpired(exp: number, leeway = 0): boolean { + return exp + leeway < Date.now() / 1000; +} + +function tryToParsePayload(input: string): unknown { + try { + return JSON.parse(input); + } catch { + return input; + } +} + +/** + * Decodes a token into an { header, payload, signature } object. + * @param token + */ +export function decode( + token: string, +): { + header: Header; + payload: unknown; + signature: string; +} { + const parsedArray = token + .split(".") + .map(base64url.decode) + .map((uint8Array, index) => { + switch (index) { + case 0: + try { + return JSON.parse(decoder.decode(uint8Array)); + } catch { + break; + } + case 1: + return tryToParsePayload(decoder.decode(uint8Array)); + case 2: + return convertUint8ArrayToHex(uint8Array); + } + throw TypeError("The serialization is invalid."); + }); + + const [header, payload, signature] = parsedArray; + + if ( + !( + (typeof signature === "string" && + typeof header?.alg === "string") && payload?.exp !== undefined + ? typeof payload.exp === "number" + : true + ) + ) { + throw new Error(`The token is invalid.`); + } + + if ( + typeof payload?.exp === "number" && + isExpired(payload.exp) + ) { + throw RangeError("The token is expired."); + } + + return { + header, + payload, + signature, + }; +} + +export type VerifyOptions = { + algorithm?: AlgorithmInput; +}; + +/** + * Verifies a token. + * @param token + * @param key + * @param object with property 'algorithm' + */ +export async function verify( + token: string, + key: string, + { algorithm = "HS512" }: VerifyOptions = {}, +): Promise { + const { header, payload, signature } = decode(token); + + if (!verifyAlgorithm(algorithm, header.alg)) { + throw new Error( + `The token's algorithm does not match the specified algorithm '${algorithm}'.`, + ); + } + + /* + * JWS §4.1.11: The "crit" (critical) Header Parameter indicates that + * extensions to this specification and/or [JWA] are being used that MUST be + * understood and processed. + */ + if ("crit" in header) { + throw new Error( + "The 'crit' header parameter is currently not supported by this module.", + ); + } + + if ( + !(await verifySignature({ + signature, + key, + algorithm: header.alg, + signingInput: token.slice(0, token.lastIndexOf(".")), + })) + ) { + throw new Error( + "The token's signature does not match the verification signature.", + ); + } + + return payload; +} + +/* + * JSW §7.1: The JWS Compact Serialization represents digitally signed or MACed + * content as a compact, URL-safe string. This string is: + * BASE64URL(UTF8(JWS Protected Header)) || '.' || + * BASE64URL(JWS Payload) || '.' || + * BASE64URL(JWS Signature) + */ +function createSigningInput(header: Header, payload: Payload): string { + return `${ + base64url.encode( + encoder.encode(JSON.stringify(header)), + ) + }.${ + base64url.encode( + encoder.encode( + typeof payload === "string" ? payload : JSON.stringify(payload), + ), + ) + }`; +} + +/** + * Creates a token. + * @param payload + * @param key + * @param object with property 'header' + */ +export async function create( + payload: Payload, + key: string, + { + header = { alg: "HS512", typ: "JWT" }, + }: { + header?: Header; + } = {}, +): Promise { + const signingInput = createSigningInput(header, payload); + const signature = await createSignature(header.alg, key, signingInput); + + return `${signingInput}.${signature}`; +} diff --git a/std/jwt/test.ts b/std/jwt/test.ts new file mode 100644 index 0000000000..deaa857e28 --- /dev/null +++ b/std/jwt/test.ts @@ -0,0 +1,304 @@ +import { create, decode, Header, Payload, verify } from "./mod.ts"; + +import { + assertEquals, + assertThrows, + assertThrowsAsync, +} from "../testing/asserts.ts"; + +const header: Header = { + alg: "HS256", + typ: "JWT", +}; + +const payload: Payload = { + name: "John Doe", +}; + +const key = "secret"; + +Deno.test({ + name: "[jwt] create", + fn: async function () { + assertEquals( + await create("", key), + "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9..B0lmJDC8zSfMJstPqLdOAWfM265-5Svj0XrACZm8DKa1y6VJA0W7d0VoGGKJo0quKxWUdf1B1ueElNk2Yl_cLw", + ); + assertEquals( + await create({}, key), + "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.e30.dGumW8J3t2BlAwqqoisyWDC6ov2hRtjTAFHzd-Tlr4DUScaHG4OYqTHXLHEzd3hU5wy5xs87vRov6QzZnj410g", + ); + assertEquals( + await create({ foo: "bar" }, key), + "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.WePl7achkd0oGNB8XRF_LJwxlyiPZqpdNgdKpDboAjSTsWq-aOGNynTp8TOv8KjonFym8vwFwppXOLoLXbkIaQ", + ); + assertEquals( + await create("null", key), + "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.bnVsbA.tv7DbhvALc5Eq2sC61Y9IZlG2G15hvJoug9UO6iwmE_UZOLva8EC-9PURg7IIj6f-F9jFWix8vCn9WaAMHR1AA", + ); + assertEquals( + await create("[]", key), + "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.W10.BqmZ-tVI9a-HDx6PpMiBdMq6lzcaqO9sW6pImw-NRajCCmRrVi6IgMhEw7lvOG6sxhteceVMl8_xFRGverJJWw", + ); + }, +}); + +Deno.test({ + name: "[jwt] verify", + fn: async function () { + assertEquals( + await verify(await create("", key, { header: header }), key, { + algorithm: "HS256", + }), + "", + ); + assertEquals( + await verify( + await create("abc", key, { header: header }), + key, + { + algorithm: "HS256", + }, + ), + "abc", + ); + + await assertEquals( + await verify(await create("null", key), key), + null, + ); + + await assertEquals( + await verify(await create("true", key), key), + true, + ); + + assertEquals( + await verify( + await create(payload, key, { header: header }), + key, + { + algorithm: "HS256", + }, + ), + payload, + ); + await assertEquals( + await verify(await create({}, key), key), + {}, + ); + await assertEquals( + await verify(await create("[]", key), key), + [], + ); + await assertEquals( + await verify(await create(`["a", 1, true]`, key), key), + ["a", 1, true], + ); + + await assertThrowsAsync( + async () => { + // payload = { "exp": false } + await verify( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOmZhbHNlfQ.LXb8M9J6ar14CTq7shnqDMWmSsoH_zyIHiD44Rqd6uI", + key, + ); + }, + Error, + "The token is invalid.", + ); + + await assertThrowsAsync( + async () => { + await verify("", key); + }, + Error, + "The serialization is invalid.", + ); + + await assertThrowsAsync( + async () => { + await verify("invalid", key); + }, + Error, + "The serialization is invalid.", + ); + + await assertThrowsAsync( + async () => { + await verify( + await create({ + // @ts-ignore */ + exp: "invalid", + }, key), + key, + ); + }, + Error, + "The token is invalid.", + ); + }, +}); + +Deno.test({ + name: "[jwt] decode", + fn: async function () { + assertEquals( + decode( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.TVCeFl1nnZWUMQkAQKuSo_I97YeIZAS8T1gOkErT7F8", + ), + { + header: { alg: "HS256", typ: "JWT" }, + payload: {}, + signature: + "4d509e165d679d959431090040ab92a3f23ded87886404bc4f580e904ad3ec5f", + }, + ); + assertThrows( + () => { + decode("aaa"); + }, + TypeError, + "The serialization is invalid.", + ); + + assertThrows( + () => { + decode("a"); + }, + TypeError, + "Illegal base64url string!", + ); + + assertThrows( + () => { + // "ImEi" === base64url("a") + decode("ImEi.ImEi.ImEi.ImEi"); + }, + TypeError, + "The serialization is invalid.", + ); + + const jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + const header: Header = { + alg: "HS256", + typ: "JWT", + }; + const payload = { + sub: "1234567890", + name: "John Doe", + iat: 1516239022, + }; + assertEquals(decode(jwt), { + header, + payload, + signature: + "49f94ac7044948c78a285d904f87f0a4c7897f7e8f3a4eb2255fda750b2cc397", + }); + assertEquals(await create(payload, "your-256-bit-secret", { header }), jwt); + }, +}); + +Deno.test({ + name: "[jwt] expired token", + fn: async function () { + const payload = { + iss: "joe", + jti: "123456789abc", + exp: 20000, + }; + const header: Header = { + alg: "HS256", + dummy: 100, + }; + + await assertThrowsAsync( + async () => { + await verify(await create({ exp: 0 }, key), key); + }, + Error, + "The token is expired.", + ); + + await assertThrowsAsync( + async () => { + await verify( + await create(payload, key, { header }), + key, + { algorithm: "HS256" }, + ); + }, + Error, + "The token is expired.", + ); + }, +}); + +Deno.test({ + name: "[jwt] none algorithm", + fn: async function () { + const payload = { + iss: "joe", + jti: "123456789abc", + }; + const header: Header = { + alg: "none", + dummy: 100, + }; + const jwt = await create(payload, key, { header }); + const validatedPayload = await verify(jwt, "keyIsIgnored", { + algorithm: "none", + }); + assertEquals(validatedPayload, payload); + }, +}); + +Deno.test({ + name: "[jwt] HS256 algorithm", + fn: async function () { + const header: Header = { + alg: "HS256", + typ: "JWT", + }; + const payload = { + sub: "1234567890", + name: "John Doe", + iat: 1516239022, + }; + const jwt = await create(payload, key, { header }); + const validatedPayload = await verify(jwt, key, { algorithm: "HS256" }); + assertEquals( + jwt, + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o", + ); + assertEquals(validatedPayload, payload); + assertThrowsAsync( + async () => { + const invalidJwt = // jwt with not supported crypto algorithm in alg header: + "eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.bQTnz6AuMJvmXXQsVPrxeQNvzDkimo7VNXxHeSBfClLufmCVZRUuyTwJF311JHuh"; + await verify(invalidJwt, "", { + algorithm: "HS256", + }); + }, + Error, + `The token's algorithm does not match the specified algorithm 'HS256'.`, + ); + }, +}); + +Deno.test({ + name: "[jwt] HS512 algorithm", + fn: async function () { + const header: Header = { alg: "HS512", typ: "JWT" }; + const payload = { + sub: "1234567890", + name: "John Doe", + admin: true, + iat: 1516239022, + }; + const jwt = await create(payload, key, { header }); + const validatedPayload = await verify(jwt, key, { algorithm: "HS512" }); + assertEquals(validatedPayload, payload); + }, +});