From a125398b6e736919c5e63ca6852f046bf4c6aa18 Mon Sep 17 00:00:00 2001 From: pilcrowOnPaper Date: Fri, 26 Apr 2024 23:17:03 +0900 Subject: [PATCH] update jwt api --- docs/pages/reference/jwt/JWTHeader.md | 23 ++ docs/pages/reference/jwt/JWTPayload.md | 32 ++ docs/pages/reference/jwt/createJWT.md | 62 ++-- docs/pages/reference/jwt/createJWTPayload.md | 35 ++ docs/pages/reference/jwt/createJWTheader.md | 19 ++ docs/pages/reference/jwt/index.md | 6 +- docs/pages/reference/main/addToDate.md | 2 +- .../reference/main/isWithinExpirationDate.md | 5 +- .../revokeAccessToken.md | 2 +- .../revokeRefreshToken.md | 2 +- src/jwt/index.ts | 322 +----------------- src/jwt/{index.test.ts => jwt.test.ts} | 245 ++++++------- src/jwt/jwt.ts | 305 +++++++++++++++++ 13 files changed, 544 insertions(+), 516 deletions(-) create mode 100644 docs/pages/reference/jwt/JWTHeader.md create mode 100644 docs/pages/reference/jwt/JWTPayload.md create mode 100644 docs/pages/reference/jwt/createJWTPayload.md create mode 100644 docs/pages/reference/jwt/createJWTheader.md rename src/jwt/{index.test.ts => jwt.test.ts} (52%) create mode 100644 src/jwt/jwt.ts diff --git a/docs/pages/reference/jwt/JWTHeader.md b/docs/pages/reference/jwt/JWTHeader.md new file mode 100644 index 0000000..74d8ce1 --- /dev/null +++ b/docs/pages/reference/jwt/JWTHeader.md @@ -0,0 +1,23 @@ +--- +title: "JWTHeader" +--- + +# `JWTHeader` + +Represents a JWT header. + +## Definition + +```ts +//$ JWTAlgorithm=/reference/jwt/JWTAlgorithm +interface JWT { + typ: "JWT"; + alg: $$JWTAlgorithm; + [header: string]: any; +} +``` + +### Properties + +- `typ` +- `alg` diff --git a/docs/pages/reference/jwt/JWTPayload.md b/docs/pages/reference/jwt/JWTPayload.md new file mode 100644 index 0000000..b7043a8 --- /dev/null +++ b/docs/pages/reference/jwt/JWTPayload.md @@ -0,0 +1,32 @@ +--- +title: "JWTPayload" +--- + +# `JWTPayload` + +Represents a JWT payload. + +## Definition + +```ts +interface JWTPayload { + exp?: number; + iss?: string; + aud?: string[] | string; + jti?: string; + nbf?: number; + sub?: string; + iat?: number; + [claim: string]: any; +} +``` + +### Properties + +- `exp` +- `iss` +- `aud` +- `jti` +- `nbf` +- `sub` +- `iat` diff --git a/docs/pages/reference/jwt/createJWT.md b/docs/pages/reference/jwt/createJWT.md index 7c62ef5..a979d3d 100644 --- a/docs/pages/reference/jwt/createJWT.md +++ b/docs/pages/reference/jwt/createJWT.md @@ -4,66 +4,48 @@ title: "createJWT()" # `createJWT()` -Creates a new JWT. Claims are not included by default and must by defined with `options`. +Creates a new JWT. The algorithm is based on the header. ## Definition ```ts -//$ JWTAlgorithm=/reference/jwt/JWTAlgorithm -//$ TimeSpan=/reference/main/TimeSpan -function createJWT( - algorithm: $$JWTAlgorithm, - key: Uint8Array, - payloadClaims: Record, - options?: { - headers?: Record; - expiresIn?: $$TimeSpan; - issuer?: string; - subject?: string; - audiences?: string[]; - notBefore?: Date; - includeIssuedTimestamp?: boolean; - jwtId?: string; - } -): Promise; +//$ JWTHeader=/reference/jwt/JWTHeader +//$ JWTPayload=/reference/jwt/JWTPayload +function createJWT(key: Uint8Array, header: $$JWTHeader, payload: $$JWTPayload): Promise; ``` ### Parameters -- `algorithm` - `key`: Secret key for HMAC, and private key for ECDSA and RSA -- `payloadClaims` -- `options`: - - `headers`: Custom headers - - `expiresIn`: How long the JWT is valid for (for `exp` claim) - - `issuer`: `iss` claim - - `subject`: `sub` claim - - `audiences`: `aud` claims - - `notBefore`: `nbf` claim - - `includeIssuedTimestamp` (default: `false`): Set to `true` to include `iat` claim - - `jwtId`: `jti` claim +- `header` +- `payload` ## Example ```ts -import { HMAC } from "oslo/crypto"; -import { createJWT, validateJWT, parseJWT } from "oslo/jwt"; -import { TimeSpan } from "oslo"; +//$ HMAC=/reference/crypto/HMAC +//$ createJWTHeader=/reference/jwt/createJWTHeader +//$ createJWTPayload=/reference/jwt/createJWTHeader +import { $$HMAC } from "oslo/crypto"; +import { createJWT, $$createJWTHeader, $$createJWTPayload } from "oslo/jwt"; +import { $$TimeSpan } from "oslo"; -const secret = await new HMAC("SHA-256").generateKey(); +const key = await new HMAC("SHA-256").generateKey(); -const payload = { - message: "hello, world" -}; +const header = createJWTHeader("HS256"); -const jwt = await createJWT("HS256", secret, payload, { - headers: { - kid - }, +const basePayload = createJWTPayload({ expiresIn: new TimeSpan(30, "d"), issuer, subject, audiences, includeIssuedTimestamp: true }); + +const payload = { + message: "hello, world", + ...basePayload +}; + +const jwt = await createJWT(key, header, payload); ``` diff --git a/docs/pages/reference/jwt/createJWTPayload.md b/docs/pages/reference/jwt/createJWTPayload.md new file mode 100644 index 0000000..2499d2a --- /dev/null +++ b/docs/pages/reference/jwt/createJWTPayload.md @@ -0,0 +1,35 @@ +--- +title: "createJWTPayload()" +--- + +# `createJWTPayload()` + +Creates a new JWT payload with registered claims. + +## Definition + +```ts +//$ TimeSpan=/reference/main/TimeSpan +//$ JWTHeader=/reference/jwt/JWTHeader +function createJWTPayload(options?: { + expiresIn?: $$TimeSpan; + issuer?: string; + subject?: string; + audiences?: string[]; + notBefore?: Date; + includeIssuedTimestamp?: boolean; + jwtId?: string; +}): $$JWTHeader; +``` + +### Parameters + +- `options`: + - `headers`: Custom headers + - `expiresIn`: How long the JWT is valid for (for `exp` claim) + - `issuer`: `iss` claim + - `subject`: `sub` claim + - `audiences`: `aud` claims + - `notBefore`: `nbf` claim + - `includeIssuedTimestamp` (default: `false`): Set to `true` to include `iat` claim + - `jwtId`: `jti` claim diff --git a/docs/pages/reference/jwt/createJWTheader.md b/docs/pages/reference/jwt/createJWTheader.md new file mode 100644 index 0000000..d5b0a4b --- /dev/null +++ b/docs/pages/reference/jwt/createJWTheader.md @@ -0,0 +1,19 @@ +--- +title: "createJWTHeader()" +--- + +# `createJWTHeader()` + +Creates a new JWT header. + +## Definition + +```ts +//$ JWTAlgorithm=/reference/jwt/JWTAlgorithm +//$ JWTHeader=/reference/jwt/JWTHeader +function createJWTHeader(algorithm: $$JWTAlgorithm): $$JWTHeader; +``` + +### Parameters + +- `algorithm` diff --git a/docs/pages/reference/jwt/index.md b/docs/pages/reference/jwt/index.md index 8d74f67..55b9778 100644 --- a/docs/pages/reference/jwt/index.md +++ b/docs/pages/reference/jwt/index.md @@ -8,18 +8,22 @@ Provides utilities for working with JWTs. Supports the following algorithms: - HMAC: `HS256`, `HS384`, `HS512` - ECDSA: `ES256`, `ES384`, `ES512` -- RSASSA-PKCS1-v1_5: `RS256`, `RS384`, `RS512` +- RSASSA-PKCS1-v1.5: `RS256`, `RS384`, `RS512` - RSASSA-PSS: `PS256`, `PS384`, `PS512` ## Functions - [`createJWT()`](/reference/jwt/createJWT) +- [`createJWTHeader()`](/reference/jwt/createJWTHeader) +- [`createJWTPayload()`](/reference/jwt/createJWTPayload) - [`parseJWT()`](/reference/jwt/parseJWT) - [`validateJWT()`](/reference/jwt/validateJWT) ## Interfaces - [`JWT`](/reference/jwt/JWT) +- [`JWTHeader`](/reference/jwt/JWTHeader) +- [`JWTPayload`](/reference/jwt/JWTPayload) ## Types diff --git a/docs/pages/reference/main/addToDate.md b/docs/pages/reference/main/addToDate.md index 53398ff..e0ba1d8 100644 --- a/docs/pages/reference/main/addToDate.md +++ b/docs/pages/reference/main/addToDate.md @@ -10,7 +10,7 @@ Creates a new `Date` by adding the provided time-span to the one provided. Suppo ```ts //$ TimeSpan=/reference/main/TimeSpan -function createDate(date: Date, timeSpan: $$TimeSpan): Date; +function addToDate(date: Date, timeSpan: $$TimeSpan): Date; ``` ### Parameters diff --git a/docs/pages/reference/main/isWithinExpirationDate.md b/docs/pages/reference/main/isWithinExpirationDate.md index 5de9bb1..5473c9e 100644 --- a/docs/pages/reference/main/isWithinExpirationDate.md +++ b/docs/pages/reference/main/isWithinExpirationDate.md @@ -20,10 +20,7 @@ function isWithinExpirationDate(expirationDate: Date): boolean; ## Example ```ts -import { createDate, TimeSpan, isWithinExpirationDate } from "oslo"; - -const tomorrow = createDate(new TimeSpan(1, "d")); -const yesterday = createDate(new TimeSpan(-1, "d")); +import { isWithinExpirationDate } from "oslo"; isWithinExpirationDate(tomorrow); // true isWithinExpirationDate(yesterday); // false diff --git a/docs/pages/reference/oauth2/OAuth2TokenRevocationClient/revokeAccessToken.md b/docs/pages/reference/oauth2/OAuth2TokenRevocationClient/revokeAccessToken.md index 7526e57..9b3226a 100644 --- a/docs/pages/reference/oauth2/OAuth2TokenRevocationClient/revokeAccessToken.md +++ b/docs/pages/reference/oauth2/OAuth2TokenRevocationClient/revokeAccessToken.md @@ -30,7 +30,7 @@ function revokeAccessToken( ```ts //$ OAuth2RequestError=/reference/oauth2/OAuth2RequestError //$ OAuth2TokenRevocationRetryError=/reference/oauth2/OAuth2TokenRevocationRetryError -import { $OAuth2RequestError, $OAuth2TokenRevocationRetryError } from "oslo/oauth2"; +import { $$OAuth2RequestError, $$OAuth2TokenRevocationRetryError } from "oslo/oauth2"; try { const url = oauth2Client.revokeAccessToken(accessToken, { diff --git a/docs/pages/reference/oauth2/OAuth2TokenRevocationClient/revokeRefreshToken.md b/docs/pages/reference/oauth2/OAuth2TokenRevocationClient/revokeRefreshToken.md index 4534f63..9df3ebd 100644 --- a/docs/pages/reference/oauth2/OAuth2TokenRevocationClient/revokeRefreshToken.md +++ b/docs/pages/reference/oauth2/OAuth2TokenRevocationClient/revokeRefreshToken.md @@ -30,7 +30,7 @@ function revokeRefreshToken( ```ts //$ OAuth2RequestError=/reference/oauth2/OAuth2RequestError //$ OAuth2TokenRevocationRetryError=/reference/oauth2/OAuth2TokenRevocationRetryError -import { $OAuth2RequestError, $OAuth2TokenRevocationRetryError } from "oslo/oauth2"; +import { $$OAuth2RequestError, $$OAuth2TokenRevocationRetryError } from "oslo/oauth2"; try { const url = oauth2Client.revokeRefreshToken(refreshToken, { diff --git a/src/jwt/index.ts b/src/jwt/index.ts index 577271e..93397f4 100644 --- a/src/jwt/index.ts +++ b/src/jwt/index.ts @@ -1,321 +1,3 @@ -import { ECDSA, HMAC, RSASSAPKCS1v1_5, RSASSAPSS } from "../crypto/index.js"; -import { base64url } from "../encoding/index.js"; -import { isWithinExpirationDate } from "../index.js"; +export { parseJWT, validateJWT, createJWT, createJWTHeader, createJWTPayload } from "./jwt.js"; -import type { TimeSpan } from "../index.js"; -import type { SigningAlgorithm } from "../crypto/index.js"; - -export type JWTAlgorithm = - | "HS256" - | "HS384" - | "HS512" - | "RS256" - | "RS384" - | "RS512" - | "ES256" - | "ES384" - | "ES512" - | "PS256" - | "PS384" - | "PS512"; - -export async function createJWT( - algorithm: JWTAlgorithm, - key: Uint8Array, - payloadClaims: Record, - options?: { - headers?: Record; - expiresIn?: TimeSpan; - issuer?: string; - subject?: string; - audiences?: string[]; - notBefore?: Date; - includeIssuedTimestamp?: boolean; - jwtId?: string; - } -): Promise { - const header: JWTHeader = { - alg: algorithm, - typ: "JWT", - ...options?.headers - }; - const payload: JWTPayload = { - ...payloadClaims - }; - if (options?.audiences !== undefined) { - payload.aud = options.audiences; - } - if (options?.subject !== undefined) { - payload.sub = options.subject; - } - if (options?.issuer !== undefined) { - payload.iss = options.issuer; - } - if (options?.jwtId !== undefined) { - payload.jti = options.jwtId; - } - if (options?.expiresIn !== undefined) { - payload.exp = Math.floor(Date.now() / 1000) + options.expiresIn.seconds(); - } - if (options?.notBefore !== undefined) { - payload.nbf = Math.floor(options.notBefore.getTime() / 1000); - } - if (options?.includeIssuedTimestamp === true) { - payload.iat = Math.floor(Date.now() / 1000); - } - const textEncoder = new TextEncoder(); - const headerPart = base64url.encode(textEncoder.encode(JSON.stringify(header)), { - includePadding: false - }); - const payloadPart = base64url.encode(textEncoder.encode(JSON.stringify(payload)), { - includePadding: false - }); - const data = textEncoder.encode([headerPart, payloadPart].join(".")); - const signature = await getAlgorithm(algorithm).sign(key, data); - const signaturePart = base64url.encode(signature, { - includePadding: false - }); - const value = [headerPart, payloadPart, signaturePart].join("."); - return value; -} - -export async function validateJWT( - algorithm: JWTAlgorithm, - key: Uint8Array, - jwt: string -): Promise { - const parsedJWT = parseJWT(jwt); - if (!parsedJWT) { - throw new Error("Invalid JWT"); - } - if (parsedJWT.algorithm !== algorithm) { - throw new Error("Invalid algorithm"); - } - if (parsedJWT.expiresAt && !isWithinExpirationDate(parsedJWT.expiresAt)) { - throw new Error("Expired JWT"); - } - if (parsedJWT.notBefore && Date.now() < parsedJWT.notBefore.getTime()) { - throw new Error("Inactive JWT"); - } - const signature = base64url.decode(parsedJWT.parts[2], { - strict: false - }); - const data = new TextEncoder().encode(parsedJWT.parts[0] + "." + parsedJWT.parts[1]); - const validSignature = await getAlgorithm(parsedJWT.algorithm).verify(key, signature, data); - if (!validSignature) { - throw new Error("Invalid signature"); - } - return parsedJWT; -} - -function getJWTParts(jwt: string): [header: string, payload: string, signature: string] | null { - const jwtParts = jwt.split("."); - if (jwtParts.length !== 3) { - return null; - } - return jwtParts as [string, string, string]; -} - -export function parseJWT(jwt: string): JWT | null { - const jwtParts = getJWTParts(jwt); - if (!jwtParts) { - return null; - } - const textDecoder = new TextDecoder(); - const rawHeader = base64url.decode(jwtParts[0], { - strict: false - }); - const rawPayload = base64url.decode(jwtParts[1], { - strict: false - }); - const header: unknown = JSON.parse(textDecoder.decode(rawHeader)); - if (typeof header !== "object" || header === null) { - return null; - } - if (!("alg" in header) || !isValidAlgorithm(header.alg)) { - return null; - } - if ("typ" in header && header.typ !== "JWT") { - return null; - } - const payload: unknown = JSON.parse(textDecoder.decode(rawPayload)); - if (typeof payload !== "object" || payload === null) { - return null; - } - const properties: JWTProperties = { - algorithm: header.alg, - expiresAt: null, - subject: null, - issuedAt: null, - issuer: null, - jwtId: null, - audiences: null, - notBefore: null - }; - if ("exp" in payload) { - if (typeof payload.exp !== "number") { - return null; - } - properties.expiresAt = new Date(payload.exp * 1000); - } - if ("iss" in payload) { - if (typeof payload.iss !== "string") { - return null; - } - properties.issuer = payload.iss; - } - if ("sub" in payload) { - if (typeof payload.sub !== "string") { - return null; - } - properties.subject = payload.sub; - } - if ("aud" in payload) { - if (!Array.isArray(payload.aud)) { - if (typeof payload.aud !== "string") { - return null; - } - properties.audiences = [payload.aud]; - } else { - for (const item of payload.aud) { - if (typeof item !== "string") { - return null; - } - } - properties.audiences = payload.aud; - } - } - if ("nbf" in payload) { - if (typeof payload.nbf !== "number") { - return null; - } - properties.notBefore = new Date(payload.nbf * 1000); - } - if ("iat" in payload) { - if (typeof payload.iat !== "number") { - return null; - } - properties.issuedAt = new Date(payload.iat * 1000); - } - if ("jti" in payload) { - if (typeof payload.jti !== "string") { - return null; - } - properties.jwtId = payload.jti; - } - return { - value: jwt, - header: { - ...header, - typ: "JWT", - alg: header.alg - }, - payload: { - ...payload - }, - parts: jwtParts, - ...properties - }; -} - -interface JWTProperties { - algorithm: JWTAlgorithm; - expiresAt: Date | null; - issuer: string | null; - subject: string | null; - audiences: string[] | null; - notBefore: Date | null; - issuedAt: Date | null; - jwtId: string | null; -} - -export interface JWT extends JWTProperties { - value: string; - header: object; - payload: object; - parts: [header: string, payload: string, signature: string]; -} - -function getAlgorithm(algorithm: JWTAlgorithm): SigningAlgorithm { - if (algorithm === "ES256" || algorithm === "ES384" || algorithm === "ES512") { - return new ECDSA(ecdsaDictionary[algorithm].hash, ecdsaDictionary[algorithm].curve); - } - if (algorithm === "HS256" || algorithm === "HS384" || algorithm === "HS512") { - return new HMAC(hmacDictionary[algorithm]); - } - if (algorithm === "RS256" || algorithm === "RS384" || algorithm === "RS512") { - return new RSASSAPKCS1v1_5(rsassapkcs1v1_5Dictionary[algorithm]); - } - if (algorithm === "PS256" || algorithm === "PS384" || algorithm === "PS512") { - return new RSASSAPSS(rsassapssDictionary[algorithm]); - } - throw new TypeError("Invalid algorithm"); -} - -function isValidAlgorithm(maybeValidAlgorithm: unknown): maybeValidAlgorithm is JWTAlgorithm { - if (typeof maybeValidAlgorithm !== "string") return false; - return [ - "HS256", - "HS384", - "HS512", - "RS256", - "RS384", - "RS512", - "ES256", - "ES384", - "ES512", - "PS256", - "PS384", - "PS512" - ].includes(maybeValidAlgorithm); -} - -interface JWTHeader { - typ: "JWT"; - alg: JWTAlgorithm; - [header: string]: any; -} - -interface JWTPayload { - exp?: number; - iss?: string; - aud?: string[] | string; - jti?: string; - nbf?: number; - sub?: string; - iat?: number; - [claim: string]: any; -} - -const ecdsaDictionary = { - ES256: { - hash: "SHA-256", - curve: "P-256" - }, - ES384: { - hash: "SHA-384", - curve: "P-384" - }, - ES512: { - hash: "SHA-512", - curve: "P-521" - } -} as const; - -const hmacDictionary = { - HS256: "SHA-256", - HS384: "SHA-384", - HS512: "SHA-512" -} as const; - -const rsassapkcs1v1_5Dictionary = { - RS256: "SHA-256", - RS384: "SHA-384", - RS512: "SHA-512" -} as const; - -const rsassapssDictionary = { - PS256: "SHA-256", - PS384: "SHA-384", - PS512: "SHA-512" -} as const; +export type { JWT, JWTAlgorithm, JWTHeader, JWTPayload } from "./jwt.js"; diff --git a/src/jwt/index.test.ts b/src/jwt/jwt.test.ts similarity index 52% rename from src/jwt/index.test.ts rename to src/jwt/jwt.test.ts index 4f07496..436f29c 100644 --- a/src/jwt/index.test.ts +++ b/src/jwt/jwt.test.ts @@ -1,10 +1,22 @@ import { describe, test, expect } from "vitest"; -import { createJWT, parseJWT, validateJWT } from "./index.js"; +import { + createJWT, + createJWTHeader, + createJWTPayload, + ecdsaDictionary, + hmacDictionary, + parseJWT, + rsassapkcs1v1_5Dictionary, + rsassapssDictionary, + validateJWT +} from "./jwt.js"; import { HMAC } from "../crypto/signing-algorithm/hmac.js"; import { ECDSA } from "../crypto/signing-algorithm/ecdsa.js"; import { RSASSAPKCS1v1_5, RSASSAPSS } from "../crypto/signing-algorithm/rsa.js"; -import { TimeSpan } from "../index.js"; +import { addToDate, TimeSpan } from "../index.js"; + +import type { JWT } from "./jwt.js"; test.each(["ES256", "ES384", "ES512"] as const)( "Create and validate JWT with %s", @@ -13,9 +25,11 @@ test.each(["ES256", "ES384", "ES512"] as const)( ecdsaDictionary[algorithm].hash, ecdsaDictionary[algorithm].curve ).generateKeyPair(); - const jwt = await createJWT(algorithm, privateKey, { + const header = createJWTHeader(algorithm); + const payload = { message: "hello" - }); + }; + const jwt = await createJWT(privateKey, header, payload); const validatedJWT = await validateJWT(algorithm, publicKey, jwt); expect(validatedJWT.algorithm).toBe(algorithm); expect(validatedJWT.header).toStrictEqual({ @@ -34,9 +48,11 @@ test.each(["RS256", "RS384", "RS512"] as const)( const { publicKey, privateKey } = await new RSASSAPKCS1v1_5( rsassapkcs1v1_5Dictionary[algorithm] ).generateKeyPair(); - const jwt = await createJWT(algorithm, privateKey, { + const header = createJWTHeader(algorithm); + const payload = { message: "hello" - }); + }; + const jwt = await createJWT(privateKey, header, payload); const validatedJWT = await validateJWT(algorithm, publicKey, jwt); expect(validatedJWT.algorithm).toBe(algorithm); expect(validatedJWT.header).toStrictEqual({ @@ -55,9 +71,11 @@ test.each(["PS256", "PS384", "PS512"] as const)( const { publicKey, privateKey } = await new RSASSAPSS( rsassapssDictionary[algorithm] ).generateKeyPair(); - const jwt = await createJWT(algorithm, privateKey, { + const header = createJWTHeader(algorithm); + const payload = { message: "hello" - }); + }; + const jwt = await createJWT(privateKey, header, payload); const validatedJWT = await validateJWT(algorithm, publicKey, jwt); expect(validatedJWT.algorithm).toBe(algorithm); expect(validatedJWT.header).toStrictEqual({ @@ -73,11 +91,13 @@ test.each(["PS256", "PS384", "PS512"] as const)( test.each(["HS256", "HS384", "HS512"] as const)( "Create and validate JWT with %s", async (algorithm) => { - const secretKey = await new HMAC(hmacDictionary[algorithm]).generateKey(); - const jwt = await createJWT(algorithm, secretKey, { + const key = await new HMAC(hmacDictionary[algorithm]).generateKey(); + const header = createJWTHeader(algorithm); + const payload = { message: "hello" - }); - const validatedJWT = await validateJWT(algorithm, secretKey, jwt); + }; + const jwt = await createJWT(key, header, payload); + const validatedJWT = await validateJWT(algorithm, key, jwt); expect(validatedJWT.algorithm).toBe(algorithm); expect(validatedJWT.header).toStrictEqual({ typ: "JWT", @@ -89,57 +109,44 @@ test.each(["HS256", "HS384", "HS512"] as const)( } ); -describe("createJWT()", () => { +describe("createJWT() and validateJWT()", () => { test("Creates the correct JWT value", async () => { - const secretKey = new Uint8Array([ + const key = new Uint8Array([ 8, 138, 53, 76, 210, 41, 194, 216, 13, 70, 56, 196, 237, 57, 69, 41, 152, 114, 223, 150, 169, 154, 191, 89, 202, 118, 249, 18, 34, 208, 18, 101, 70, 236, 76, 178, 117, 129, 106, 71, 253, 79, 99, 9, 64, 208, 102, 50, 118, 72, 107, 46, 120, 2, 240, 217, 103, 66, 63, 52, 248, 23, 140, 46 ]); - const result = await createJWT( - "HS256", - secretKey, - { - message: "hello", - count: 100 - }, - { - audiences: ["_audience"], - issuer: "_issuer", - subject: "_subject", - jwtId: "_jwtId" - } - ); - const expected = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8iLCJjb3VudCI6MTAwLCJhdWQiOlsiX2F1ZGllbmNlIl0sInN1YiI6Il9zdWJqZWN0IiwiaXNzIjoiX2lzc3VlciIsImp0aSI6Il9qd3RJZCJ9.cKi5L4ZV79IHtpC-rXRwjnQIeWdswAvv1KavDSM_vds"; - expect(result).toBe(expected); + const header = createJWTHeader("HS256"); + const payload = createJWTPayload({ + audiences: ["_audience"], + issuer: "_issuer", + subject: "_subject", + jwtId: "_jwtId" + }); + payload.message = "hello"; + payload.count = 100; + expect(createJWT(key, header, payload)).resolves.not.toThrowError(); }); }); test("parseJWT()", async () => { - const secretKey = await new HMAC("SHA-256").generateKey(); + const key = await new HMAC("SHA-256").generateKey(); const currDateSeconds = Math.floor(Date.now() / 1000); - const jwt = await createJWT( - "HS256", - secretKey, - { - message: "hello" - }, - { - audiences: ["_audience"], - issuer: "_issuer", - subject: "_subject", - jwtId: "_jwtId", - expiresIn: new TimeSpan(1, "h"), - notBefore: new Date(), - includeIssuedTimestamp: true, - headers: { - kid: "_kid" - } - } - ); - expect(parseJWT(jwt)).toEqual({ + const header = createJWTHeader("HS256"); + header.kid = "_kid"; + const payload = createJWTPayload({ + audiences: ["_audience"], + issuer: "_issuer", + subject: "_subject", + jwtId: "_jwtId", + expiresIn: new TimeSpan(1, "h"), + notBefore: new Date(), + includeIssuedTimestamp: true + }); + payload.message = "hello"; + const jwt = await createJWT(key, header, payload); + const expected: JWT = { algorithm: "HS256", expiresAt: new Date((currDateSeconds + new TimeSpan(1, "h").seconds()) * 1000), notBefore: new Date(currDateSeconds * 1000), @@ -149,7 +156,7 @@ test("parseJWT()", async () => { subject: "_subject", jwtId: "_jwtId", value: jwt, - parts: jwt.split("."), + parts: jwt.split(".") as [string, string, string], header: { kid: "_kid", typ: "JWT", @@ -165,115 +172,57 @@ test("parseJWT()", async () => { iat: currDateSeconds, nbf: currDateSeconds } - }); + }; + expect(parseJWT(jwt)).toEqual(expected); }); -describe("validateJWT", () => { +describe("validateJWT()", () => { test("Checks expiration", async () => { - const secretKey = await new HMAC("SHA-256").generateKey(); - const jwt1 = await createJWT( - "HS256", - secretKey, - {}, - { - expiresIn: new TimeSpan(-1, "s") - } - ); - const jwt2 = await createJWT( - "HS256", - secretKey, - {}, - { - expiresIn: new TimeSpan(0, "s") - } - ); - await expect(validateJWT("HS256", secretKey, jwt1)).rejects.toThrowError(); - await expect(validateJWT("HS256", secretKey, jwt2)).rejects.toThrowError(); + const key = await new HMAC("SHA-256").generateKey(); + const header = createJWTHeader("HS256"); + const payload1 = createJWTPayload({ + expiresIn: new TimeSpan(-1, "s") + }); + const payload2 = createJWTPayload({ + expiresIn: new TimeSpan(0, "s") + }); + const jwt1 = await createJWT(key, header, payload1); + const jwt2 = await createJWT(key, header, payload2); + await expect(validateJWT("HS256", key, jwt1)).rejects.toThrowError(); + await expect(validateJWT("HS256", key, jwt2)).rejects.toThrowError(); }); test("Checks not before time", async () => { - const secretKey = await new HMAC("SHA-256").generateKey(); - const jwt1 = await createJWT( - "HS256", - secretKey, - {}, - { - notBefore: new Date(Date.now() + 1000) - } - ); - const jwt2 = await createJWT( - "HS256", - secretKey, - {}, - { - notBefore: new Date() - } - ); - await expect(validateJWT("HS256", secretKey, jwt1)).rejects.toThrowError(); - await expect(validateJWT("HS256", secretKey, jwt2)).resolves.not.toThrowError(); + const key = await new HMAC("SHA-256").generateKey(); + const header = createJWTHeader("HS256"); + const payload1 = createJWTPayload({ + notBefore: addToDate(new Date(), new TimeSpan(60, "s")) + }); + const payload2 = createJWTPayload({ + notBefore: new Date() + }); + const jwt1 = await createJWT(key, header, payload1); + const jwt2 = await createJWT(key, header, payload2); + await expect(validateJWT("HS256", key, jwt1)).rejects.toThrowError(); + await expect(validateJWT("HS256", key, jwt2)).resolves.not.toThrowError(); }); test("Throws on invalid algorithm", async () => { - const secretKey = await new HMAC("SHA-256").generateKey(); - const jwt = await createJWT( - "HS256", - secretKey, - {}, - { - notBefore: new Date(Date.now() + 1000) - } - ); - await expect(validateJWT("HS512", secretKey, jwt)).rejects.toThrowError(); + const key = await new HMAC("SHA-256").generateKey(); + const header = createJWTHeader("HS256"); + const jwt = await createJWT(key, header, {}); + await expect(validateJWT("HS512", key, jwt)).rejects.toThrowError(); }); test("Throws on invalid signature", async () => { - const secretKey = await new HMAC("SHA-256").generateKey(); - const jwt = await createJWT( - "HS256", - secretKey, - {}, - { - notBefore: new Date(Date.now() + 1000) - } - ); + const key = await new HMAC("SHA-256").generateKey(); + const header = createJWTHeader("HS256"); + const jwt = await createJWT(key, header, {}); const invalidKey = await new HMAC("SHA-256").generateKey(); await expect(validateJWT("HS512", invalidKey, jwt)).rejects.toThrowError(); }); test("Throws on invalid JWT", async () => { - const secretKey = await new HMAC("SHA-256").generateKey(); - await expect(validateJWT("HS256", secretKey, "huhuihdeuihdiheud")).rejects.toThrowError(); + const key = await new HMAC("SHA-256").generateKey(); + await expect(validateJWT("HS256", key, "huhuihdeuihdiheud")).rejects.toThrowError(); await expect( - validateJWT("HS256", secretKey, "huhuihdeuihdiheudheiuhdehd.dededed.deded") + validateJWT("HS256", key, "huhuihdeuihdiheudheiuhdehd.dededed.deded") ).rejects.toThrowError(); }); }); - -const ecdsaDictionary = { - ES256: { - hash: "SHA-256", - curve: "P-256" - }, - ES384: { - hash: "SHA-384", - curve: "P-384" - }, - ES512: { - hash: "SHA-512", - curve: "P-521" - } -} as const; - -const hmacDictionary = { - HS256: "SHA-256", - HS384: "SHA-384", - HS512: "SHA-512" -} as const; - -const rsassapkcs1v1_5Dictionary = { - RS256: "SHA-256", - RS384: "SHA-384", - RS512: "SHA-512" -} as const; - -const rsassapssDictionary = { - PS256: "SHA-256", - PS384: "SHA-384", - PS512: "SHA-512" -} as const; diff --git a/src/jwt/jwt.ts b/src/jwt/jwt.ts new file mode 100644 index 0000000..967c3d4 --- /dev/null +++ b/src/jwt/jwt.ts @@ -0,0 +1,305 @@ +import { ECDSA, HMAC, RSASSAPKCS1v1_5, RSASSAPSS } from "../crypto/index.js"; +import { base64url } from "../encoding/index.js"; +import { isWithinExpirationDate } from "../index.js"; + +import type { TimeSpan } from "../index.js"; +import type { SigningAlgorithm } from "../crypto/index.js"; + +export type JWTAlgorithm = + | "HS256" + | "HS384" + | "HS512" + | "RS256" + | "RS384" + | "RS512" + | "ES256" + | "ES384" + | "ES512" + | "PS256" + | "PS384" + | "PS512"; + +export function createJWTHeader(algorithm: JWTAlgorithm): JWTHeader { + const header: JWTHeader = { + alg: algorithm, + typ: "JWT" + }; + return header; +} + +export function createJWTPayload(options?: { + expiresIn?: TimeSpan; + issuer?: string; + subject?: string; + audiences?: string[]; + notBefore?: Date; + includeIssuedTimestamp?: boolean; + jwtId?: string; +}): JWTPayload { + const payload: JWTPayload = {}; + if (options?.audiences !== undefined) { + payload.aud = options.audiences; + } + if (options?.subject !== undefined) { + payload.sub = options.subject; + } + if (options?.issuer !== undefined) { + payload.iss = options.issuer; + } + if (options?.jwtId !== undefined) { + payload.jti = options.jwtId; + } + if (options?.expiresIn !== undefined) { + payload.exp = Math.floor(Date.now() / 1000) + options.expiresIn.seconds(); + } + if (options?.notBefore !== undefined) { + payload.nbf = Math.floor(options.notBefore.getTime() / 1000); + } + if (options?.includeIssuedTimestamp === true) { + payload.iat = Math.floor(Date.now() / 1000); + } + return payload; +} + +export async function createJWT( + key: Uint8Array, + header: JWTHeader, + payload: JWTPayload +): Promise { + const textEncoder = new TextEncoder(); + const headerPart = base64url.encode(textEncoder.encode(JSON.stringify(header)), { + includePadding: false + }); + const payloadPart = base64url.encode(textEncoder.encode(JSON.stringify(payload)), { + includePadding: false + }); + const data = textEncoder.encode([headerPart, payloadPart].join(".")); + const signature = await getAlgorithm(header.alg).sign(key, data); + const signaturePart = base64url.encode(signature, { + includePadding: false + }); + const value = [headerPart, payloadPart, signaturePart].join("."); + return value; +} + +export async function validateJWT( + algorithm: JWTAlgorithm, + key: Uint8Array, + jwt: string +): Promise { + const parsedJWT = parseJWT(jwt); + if (!parsedJWT) { + throw new Error("Invalid JWT"); + } + if (parsedJWT.algorithm !== algorithm) { + throw new Error("Invalid algorithm"); + } + if (parsedJWT.expiresAt && !isWithinExpirationDate(parsedJWT.expiresAt)) { + throw new Error("Expired JWT"); + } + if (parsedJWT.notBefore && Date.now() < parsedJWT.notBefore.getTime()) { + throw new Error("Inactive JWT"); + } + const signature = base64url.decode(parsedJWT.parts[2], { + strict: false + }); + const data = new TextEncoder().encode(parsedJWT.parts[0] + "." + parsedJWT.parts[1]); + const validSignature = await getAlgorithm(parsedJWT.algorithm).verify(key, signature, data); + if (!validSignature) { + throw new Error("Invalid signature"); + } + return parsedJWT; +} + +export function parseJWT(token: string): JWT | null { + const parts = token.split("."); + if (parts.length !== 3) { + return null; + } + const textDecoder = new TextDecoder(); + const rawHeader = base64url.decode(parts[0], { + strict: false + }); + const rawPayload = base64url.decode(parts[1], { + strict: false + }); + const header: unknown = JSON.parse(textDecoder.decode(rawHeader)); + if (typeof header !== "object" || header === null) { + return null; + } + if (!("alg" in header) || !isValidAlgorithm(header.alg)) { + return null; + } + if ("typ" in header && header.typ !== "JWT") { + return null; + } + const payload: unknown = JSON.parse(textDecoder.decode(rawPayload)); + if (typeof payload !== "object" || payload === null) { + return null; + } + const jwt: JWT = { + value: token, + header: header, + payload: payload, + parts: parts as [string, string, string], + expiresAt: null, + issuer: null, + subject: null, + audiences: null, + notBefore: null, + issuedAt: null, + jwtId: null, + algorithm: header.alg + }; + if ("exp" in payload) { + if (typeof payload.exp !== "number") { + return null; + } + jwt.expiresAt = new Date(payload.exp * 1000); + } + if ("iss" in payload) { + if (typeof payload.iss !== "string") { + return null; + } + jwt.issuer = payload.iss; + } + if ("sub" in payload) { + if (typeof payload.sub !== "string") { + return null; + } + jwt.subject = payload.sub; + } + if ("aud" in payload) { + if (!Array.isArray(payload.aud)) { + if (typeof payload.aud !== "string") { + return null; + } + jwt.audiences = [payload.aud]; + } else { + for (const item of payload.aud) { + if (typeof item !== "string") { + return null; + } + } + jwt.audiences = payload.aud; + } + } + if ("nbf" in payload) { + if (typeof payload.nbf !== "number") { + return null; + } + jwt.notBefore = new Date(payload.nbf * 1000); + } + if ("iat" in payload) { + if (typeof payload.iat !== "number") { + return null; + } + jwt.issuedAt = new Date(payload.iat * 1000); + } + if ("jti" in payload) { + if (typeof payload.jti !== "string") { + return null; + } + jwt.jwtId = payload.jti; + } + return jwt; +} + +export interface JWT { + value: string; + header: object; + payload: object; + parts: [header: string, payload: string, signature: string]; + algorithm: JWTAlgorithm; + expiresAt: Date | null; + issuer: string | null; + subject: string | null; + audiences: string[] | null; + notBefore: Date | null; + issuedAt: Date | null; + jwtId: string | null; +} + +function getAlgorithm(algorithm: JWTAlgorithm): SigningAlgorithm { + if (algorithm === "ES256" || algorithm === "ES384" || algorithm === "ES512") { + return new ECDSA(ecdsaDictionary[algorithm].hash, ecdsaDictionary[algorithm].curve); + } + if (algorithm === "HS256" || algorithm === "HS384" || algorithm === "HS512") { + return new HMAC(hmacDictionary[algorithm]); + } + if (algorithm === "RS256" || algorithm === "RS384" || algorithm === "RS512") { + return new RSASSAPKCS1v1_5(rsassapkcs1v1_5Dictionary[algorithm]); + } + if (algorithm === "PS256" || algorithm === "PS384" || algorithm === "PS512") { + return new RSASSAPSS(rsassapssDictionary[algorithm]); + } + throw new TypeError("Invalid algorithm"); +} + +function isValidAlgorithm(maybeValidAlgorithm: unknown): maybeValidAlgorithm is JWTAlgorithm { + if (typeof maybeValidAlgorithm !== "string") return false; + return [ + "HS256", + "HS384", + "HS512", + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", + "PS256", + "PS384", + "PS512" + ].includes(maybeValidAlgorithm); +} + +export interface JWTHeader { + typ: "JWT"; + alg: JWTAlgorithm; + [header: string]: any; +} + +export interface JWTPayload { + exp?: number; + iss?: string; + aud?: string[] | string; + jti?: string; + nbf?: number; + sub?: string; + iat?: number; + [claim: string]: any; +} + +export const ecdsaDictionary = { + ES256: { + hash: "SHA-256", + curve: "P-256" + }, + ES384: { + hash: "SHA-384", + curve: "P-384" + }, + ES512: { + hash: "SHA-512", + curve: "P-521" + } +} as const; + +export const hmacDictionary = { + HS256: "SHA-256", + HS384: "SHA-384", + HS512: "SHA-512" +} as const; + +export const rsassapkcs1v1_5Dictionary = { + RS256: "SHA-256", + RS384: "SHA-384", + RS512: "SHA-512" +} as const; + +export const rsassapssDictionary = { + PS256: "SHA-256", + PS384: "SHA-384", + PS512: "SHA-512" +} as const;