diff --git a/index.ts b/index.ts index 857edfd..617689f 100644 --- a/index.ts +++ b/index.ts @@ -1,2 +1,3 @@ export * as signature from 'signature' export * as boxes from 'boxes' +export * as JWT from 'jwt' diff --git a/package.json b/package.json index d63b8c8..aaa8ff1 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { + "jose": "^5.3.0", "result": "git+git@git.pband.ch:typescript/result", "misc": "git+git@git.pband.ch:typescript/misc", "zxcvbn": "^4.4.2" diff --git a/src/jwt.ts b/src/jwt.ts new file mode 100644 index 0000000..ff27015 --- /dev/null +++ b/src/jwt.ts @@ -0,0 +1,81 @@ +import {Result} from 'result' +import * as jose from 'jose' + +export type JWTstring = `${string}.${string}.${string}` +export type JWTalgorithm = "HS256" | "HS512" | "ES256" | "ES512" | "EdDSA" +export type Key = jose.KeyLike | Uint8Array +/** KeyPair for asymmetric algorithms, [PrivKey, PubKey] */ +export type KeyPair = [Key, Key] + +export class JWTcontext { + public constructor( + private readonly key: Key | KeyPair, + private readonly alg: JWTalgorithm, + ) {} + + public static async gen_key(alg: JWTalgorithm, extractable: boolean = false) : Promise { + switch (alg) { + case "HS256": + case "HS512": + return jose.generateSecret(alg, { extractable }) + case "EdDSA": + case "ES256": + case "ES512": + { + const key = await jose.generateKeyPair(alg) + return [key.privateKey, key.publicKey] + } + } + } + + public async sign(message: T, set_issued: boolean = false, exp?: number | string | Date, audience?: string | string[], issuer?: string): Promise> { + let jwt = new jose.SignJWT({payload: message}).setProtectedHeader({ alg: this.alg }) + + if (set_issued) jwt = jwt.setIssuedAt() + if (issuer !== undefined) jwt = jwt.setIssuer(issuer) + if (audience !== undefined) jwt = jwt.setAudience(audience) + if (exp !== undefined) jwt = jwt.setExpirationTime(exp) + + const key = this.get_key(true) + const res = await jwt.sign(key) as JWTstring + + return new JWT(res) + } + + public async verify(jwt: JWT, audience?: string | string[], issuer?: string | string[]): Promise> { + const key = this.get_key(false) + + try { + let payload = await jose.jwtVerify(jwt.jwt, key) + return Result.ok(payload.payload.payload as T) + } catch(_) {} + + return Result.error([]) + } + + private get_key(sign: boolean) : Key { + switch (this.alg) { + case "HS256": + case "HS512": + return this.key as Key + case "ES256": + case "ES512": + case "EdDSA": + return (this.key as KeyPair)[sign ? 0 : 1] + } + } +} + +export class JWT { + public constructor( + public readonly _jwt: `${string}.${string}.${string}` + ) {} + + public get payload() : T { + return jose.decodeJwt(this.jwt).payload as T + } + + public get jwt() : JWTstring { + return this._jwt + } +} diff --git a/test/jwt.test.ts b/test/jwt.test.ts new file mode 100644 index 0000000..00fabbb --- /dev/null +++ b/test/jwt.test.ts @@ -0,0 +1,41 @@ +import {beforeAll, expect, test} from 'bun:test' +import {JWTcontext, JWTalgorithm} from '../src/jwt' + +const algs: JWTalgorithm[] = ["HS256", "HS512", "ES256", "ES512", "EdDSA"] +const contexts: Map = new Map() + +beforeAll(async () => { + for (const alg of algs) { + const key = await JWTcontext.gen_key(alg as JWTalgorithm) + expect(key).not.toBeUndefined() + + const context = new JWTcontext(key, alg as JWTalgorithm) + contexts.set(alg as JWTalgorithm, context) + } +}) + +test('Base case', async () => { + let payload = { + yeet: "yaat", + lol: "yes" + } + + // @ts-expect-error : idc bout your iterator thingy >:( + for (const context of contexts.values()) { + const jwt = await context.sign(payload, true, "2 days", "pascal", "server") + + const decoded = jwt.payload + expect(decoded).toEqual(payload) + + const verified = (await context.verify(jwt, "pascal", "server")).expect("Should verify the JWT") + expect(verified).toEqual(payload) + } +}) + +test.todo("Multiple audience can be verified") +test.todo("Multiple issuer can be verified") +test.todo("Wrong audience is rejected") +test.todo("Wrong issuer is rejected") +test.todo("Expired JWT is rejected") +test.todo("Wrong key won't decrypt") +test.todo("tampered JWT are rejected (test 3 parts) (TODO : decode ?)")