diff --git a/docs/guides/jwt.md b/docs/guides/jwt.md new file mode 100644 index 0000000000..5240303db6 --- /dev/null +++ b/docs/guides/jwt.md @@ -0,0 +1,69 @@ +# JWT usage + +## Generating JWT + +Use `signJwt` to generate a JWT signed by an account provided in arguments. +```ts +import { MemoryAccount, signJwt } from '@aeternity/aepp-sdk'; + +const account = MemoryAccount.generate(); +const payload = { test: 'data' }; +const jwt = await signJwt(payload, account); +``` + +Provide `sub_jwk: undefined` in payload to omit signer public key added by default. +Do it to make JWT shorter. +```ts +const jwt = await signJwt({ test: 'data', sub_jwk: undefined }, account); +``` + +Or if you using a different way to encode a signer address. +```ts +const payload = { + test: 'data', + sub_jwk: undefined, + address: 'ak_21A27UVVt3hDkBE5J7rhhqnH5YNb4Y1dqo4PnSybrH85pnWo7E', +} +const jwt = await signJwt(payload, account); +``` + +## Verifying JWT + +Let's assume we got a JWT as string. Firstly we need to ensure that it has the right format. +```ts +import { isJwt, ensureJwt } from '@aeternity/aepp-sdk'; + +if (!isJwt(jwt)) throw new Error('Invalid JWT'); +// alternatively, +ensureJwt(jwt); +``` + +After that we can pass JWT to other SDK's methods, for example to get JWT payload and signer address +in case JWT has the signer public key included in `"sub_jwk"`. +```ts +import { unpackJwt } from '@aeternity/aepp-sdk'; + +const { payload, signer } = unpackJwt(jwt); +console.log(payload); // { test: 'data', sub_jwk: { ... } } +console.log(signer); // 'ak_21A27UVVt3hDkBE5J7rhhqnH5YNb4Y1dqo4PnSybrH85pnWo7E' +``` +`unpackJwt` will also check the JWT signature in this case. + +Alternatively, if `"sub_jwk"` is not included then we can provide signer address to `unpackJwt`. +```ts +const knownSigner = 'ak_21A27UVVt3hDkBE5J7rhhqnH5YNb4Y1dqo4PnSybrH85pnWo7E'; +const { payload, signer } = unpackJwt(jwt, knownSigner); +console.log(payload); // { test: 'data' } +console.log(signer); // 'ak_21A27UVVt3hDkBE5J7rhhqnH5YNb4Y1dqo4PnSybrH85pnWo7E' +``` + +If we need to a get signer address based on JWT payload then we need to unpack it without checking +the signature. Don't forget to check signature after that using `verifyJwt`. +```ts +import { verifyJwt } from '@aeternity/aepp-sdk'; + +const { payload, signer } = unpackJwt(jwt); +console.log(payload); // { address: 'ak_21A27UVVt3hDkBE5J7rhhqnH5YNb4Y1dqo4PnSybrH85pnWo7E' } +console.log(signer); // undefined +if (!verifyJwt(jwt, payload.address)) throw new Error('JWT signature is invalid'); +``` diff --git a/examples/browser/aepp/src/App.vue b/examples/browser/aepp/src/App.vue index 8bb57f0151..402ffeeeb7 100644 --- a/examples/browser/aepp/src/App.vue +++ b/examples/browser/aepp/src/App.vue @@ -39,6 +39,13 @@ > Delegation signature + + JWT + ({ view: '' }), }; diff --git a/examples/browser/aepp/src/Jwt.vue b/examples/browser/aepp/src/Jwt.vue new file mode 100644 index 0000000000..4266462b6f --- /dev/null +++ b/examples/browser/aepp/src/Jwt.vue @@ -0,0 +1,89 @@ + + + diff --git a/mkdocs.yml b/mkdocs.yml index 2d02f3f0f0..488d98863e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,6 +59,7 @@ nav: - guides/error-handling.md - guides/low-vs-high-usage.md - guides/typed-data.md + - guides/jwt.md - Wallet interaction: - guides/connect-aepp-to-wallet.md - guides/build-wallet.md diff --git a/src/index-browser.ts b/src/index-browser.ts index edbae28ddf..af54c5c211 100644 --- a/src/index-browser.ts +++ b/src/index-browser.ts @@ -10,6 +10,10 @@ export { generateKeyPairFromSecret, generateKeyPair, sign, verify, messageToHash, signMessage, verifyMessage, isValidKeypair, } from './utils/crypto'; +export { + signJwt, unpackJwt, verifyJwt, isJwt, ensureJwt, +} from './utils/jwt'; +export type { Jwt } from './utils/jwt'; export { recover, dump } from './utils/keystore'; export type { Keystore } from './utils/keystore'; export { toBytes } from './utils/bytes'; diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts new file mode 100644 index 0000000000..5b34e3e41f --- /dev/null +++ b/src/utils/jwt.ts @@ -0,0 +1,126 @@ +import canonicalize from 'canonicalize'; +import AccountBase from '../account/Base'; +import { + Encoded, Encoding, decode, encode, +} from './encoder'; +import { verify } from './crypto'; +import { ArgumentError, InvalidSignatureError } from './errors'; + +// TODO: use Buffer.from(data, 'base64url') after solving https://github.com/feross/buffer/issues/309 +const toBase64Url = (data: Buffer | Uint8Array | string): string => Buffer + .from(data) + .toString('base64') + .replaceAll('/', '_') + .replaceAll('+', '-') + .replace(/=+$/, ''); + +const fromBase64Url = (data: string): Buffer => Buffer + .from(data.replaceAll('_', '/').replaceAll('-', '+'), 'base64'); + +const objectToBase64Url = (data: any): string => toBase64Url(canonicalize(data) ?? ''); + +const header = 'eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9'; // objectToBase64Url({ alg: 'EdDSA', typ: 'JWT' }) + +/** + * JWT including specific header + * @category JWT + */ +export type Jwt = `${typeof header}.${string}.${string}`; + +/** + * Generate a signed JWT + * Provide `"sub_jwk": undefined` in payload to omit signer public key added by default. + * @param originalPayload - Payload to sign + * @param account - Account to sign by + * @category JWT + */ +export async function signJwt(originalPayload: any, account: AccountBase): Promise { + const payload = { ...originalPayload }; + if (!('sub_jwk' in payload)) { + payload.sub_jwk = { + kty: 'OKP', + crv: 'Ed25519', + x: toBase64Url(decode(account.address)), + }; + } + if (payload.sub_jwk === undefined) delete payload.sub_jwk; + const body = `${header}.${objectToBase64Url(payload)}` as const; + const signature = await account.sign(body); + return `${body}.${toBase64Url(signature)}`; +} + +/** + * Unpack JWT. It will check signature if address or "sub_jwk" provided. + * @param jwt - JWT to unpack + * @param address - Address to check signature + * @category JWT + */ +export function unpackJwt(jwt: Jwt, address?: Encoded.AccountAddress): { + /** + * JWT payload as object + */ + payload: any; + /** + * Undefined returned in case signature is not checked + */ + signer: Encoded.AccountAddress | undefined; +} { + const components = jwt.split('.'); + if (components.length !== 3) throw new ArgumentError('JWT components count', 3, components.length); + const [h, payloadEncoded, signature] = components; + if (h !== header) throw new ArgumentError('JWT header', header, h); + const payload = JSON.parse(fromBase64Url(payloadEncoded).toString()); + const jwk = payload.sub_jwk ?? {}; + const signer = jwk.x == null || jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' + ? address + : encode(fromBase64Url(jwk.x), Encoding.AccountAddress); + if (address != null && signer !== address) { + throw new ArgumentError('address', `${signer} ("sub_jwk")`, address); + } + if ( + signer != null + && !verify(Buffer.from(`${h}.${payloadEncoded}`), fromBase64Url(signature), signer) + ) { + throw new InvalidSignatureError(`JWT is not signed by ${signer}`); + } + return { payload, signer }; +} + +/** + * Check is string a JWT or not. Use to validate the user input. + * @param maybeJwt - A string to check + * @returns True if argument is a JWT + * @category JWT + */ +export function isJwt(maybeJwt: string): maybeJwt is Jwt { + try { + unpackJwt(maybeJwt as Jwt); + return true; + } catch (error) { + return false; + } +} + +/** + * Throws an error if argument is not JWT. Use to ensure that a value is JWT. + * @param maybeJwt - A string to check + * @category JWT + */ +export function ensureJwt(maybeJwt: string): asserts maybeJwt is Jwt { + unpackJwt(maybeJwt as Jwt); +} + +/** + * Check is JWT signed by address from arguments or "sub_jwk" + * @param jwt - JWT to check + * @param address - Address to check signature + * @category JWT + */ +export function verifyJwt(jwt: Jwt, address?: Encoded.AccountAddress): boolean { + try { + const { signer } = unpackJwt(jwt, address); + return signer != null; + } catch (error) { + return false; + } +} diff --git a/test/unit/jwt.ts b/test/unit/jwt.ts new file mode 100644 index 0000000000..337303800a --- /dev/null +++ b/test/unit/jwt.ts @@ -0,0 +1,103 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { + signJwt, unpackJwt, verifyJwt, isJwt, ensureJwt, + MemoryAccount, ArgumentError, InvalidSignatureError, +} from '../../src'; + +describe('JWT', () => { + const account = new MemoryAccount('9ebd7beda0c79af72a42ece3821a56eff16359b6df376cf049aee995565f022f840c974b97164776454ba119d84edc4d6058a8dec92b6edc578ab2d30b4c4200'); + const payload = { test: 'data' }; + const jwt = 'eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzdWJfandrIjp7ImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiaEF5WFM1Y1dSM1pGUzZFWjJFN2NUV0JZcU43SksyN2NWNHF5MHd0TVFnQSJ9LCJ0ZXN0IjoiZGF0YSJ9.u9El4b2O2LRhvTTW3g46vk1hx0xXWPkJEaEeEy-rLzLr2yuQlNc7qIdcr_z06BgHx5jyYv2CpUL3hqLpc0RzBA'; + const jwtWithAddress = 'eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJhZGRyZXNzIjoiYWtfMjFBMjdVVlZ0M2hEa0JFNUo3cmhocW5INVlOYjRZMWRxbzRQblN5YnJIODVwbldvN0UifQ._munmgMvg9SE6jJaTYd6tBSV7EtqO_YRV4TkZjQfop6W18hm_fAPWNbwNupS8doaOs2corl4Uc26zUq1Jyl6Bg'; + const jwtShortest = 'eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.e30.4L-jN-e5p23BHfOYX04CzLBSovALIiM4OEghM6xQAgGbl1g-ANXxFa-DZ3igIo21wemZ7gtlyD_fS-2Y9BWFDQ'; + + describe('isJwt, ensureJwt', () => { + it('works if correct jwt', () => { + expect(isJwt(jwt)).to.be.equal(true); + ensureJwt(jwt); + }); + + it('fails if wrong jwt', () => { + const j = 'test'; + expect(isJwt(j)).to.be.equal(false); + expect(() => ensureJwt(j)).to.throw(ArgumentError, 'JWT components count should be 3, got 1 instead'); + }); + }); + + describe('signJwt', () => { + it('signs', async () => { + expect(await signJwt(payload, account)).to.be.equal(jwt); + }); + + it('signs with address', async () => { + expect(await signJwt({ address: account.address, sub_jwk: undefined }, account)) + .to.be.equal(jwtWithAddress); + }); + + it('signs shortest', async () => { + expect(await signJwt({ sub_jwk: undefined }, account)).to.be.equal(jwtShortest); + }); + }); + + describe('unpackJwt', () => { + it('unpacks', async () => { + expect(unpackJwt(jwt)).to.be.eql({ + payload: { + sub_jwk: { + crv: 'Ed25519', + kty: 'OKP', + x: 'hAyXS5cWR3ZFS6EZ2E7cTWBYqN7JK27cV4qy0wtMQgA', + }, + test: 'data', + }, + signer: account.address, + }); + }); + + it('fails if address not the same as in "sub_jwk"', () => { + const { address } = MemoryAccount.generate(); + expect(() => unpackJwt(jwt, address)) + .to.throw(ArgumentError, `address should be ${account.address} ("sub_jwk"), got ${address} instead`); + }); + + it('unpacks with address', async () => { + expect(unpackJwt(jwtWithAddress)).to.be.eql({ + payload: { address: account.address }, + signer: undefined, + }); + expect(unpackJwt(jwtWithAddress, account.address)).to.be.eql({ + payload: { address: account.address }, + signer: account.address, + }); + }); + + it('unpacks shortest', async () => { + expect(unpackJwt(jwtShortest)).to.be.eql({ payload: {}, signer: undefined }); + expect(unpackJwt(jwtShortest, account.address)) + .to.be.eql({ payload: {}, signer: account.address }); + }); + + it('fails if wrong signature', () => { + const { address } = MemoryAccount.generate(); + expect(() => unpackJwt(jwtShortest, address)) + .to.throw(InvalidSignatureError, `JWT is not signed by ${address}`); + }); + }); + + describe('verifyJwt', () => { + it('verifies', () => { + expect(verifyJwt(jwt)).to.be.equal(true); + expect(verifyJwt(jwt, account.address)).to.be.equal(true); + expect(verifyJwt(jwtShortest, account.address)).to.be.equal(true); + }); + + it('returns false if address not the same as in "sub_jwk"', () => { + expect(verifyJwt(jwt, MemoryAccount.generate().address)).to.be.equal(false); + }); + + it('returns false if address not provided', () => { + expect(verifyJwt(jwtShortest)).to.be.equal(false); + }); + }); +});