Skip to content

Commit

Permalink
feat: jwt utilities
Browse files Browse the repository at this point in the history
  • Loading branch information
thepiwo authored and davidyuk committed Apr 5, 2024
1 parent df2d76c commit c747ce6
Show file tree
Hide file tree
Showing 7 changed files with 401 additions and 1 deletion.
69 changes: 69 additions & 0 deletions docs/guides/jwt.md
Original file line number Diff line number Diff line change
@@ -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');
```
10 changes: 9 additions & 1 deletion examples/browser/aepp/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@
>
Delegation signature
</a>
<a
href="#"
:class="{ active: view === 'Jwt' }"
@click="view = 'Jwt'"
>
JWT
</a>
</div>

<Component
Expand All @@ -54,10 +61,11 @@ import Contracts from './Contracts.vue';
import PayForTx from './PayForTx.vue';
import TypedData from './TypedData.vue';
import DelegationSignature from './DelegationSignature.vue';
import Jwt from './Jwt.vue';
export default {
components: {
Connect, Basic, Contracts, PayForTx, TypedData, DelegationSignature,
Connect, Basic, Contracts, PayForTx, TypedData, DelegationSignature, Jwt,
},
data: () => ({ view: '' }),
};
Expand Down
89 changes: 89 additions & 0 deletions examples/browser/aepp/src/Jwt.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<template>
<h2>Generate a JWT</h2>
<div class="group">
<div>
<div>Payload as JSON</div>
<div>
<input
:value="payloadAsJson"
@input="payloadAsJson = $event.target.value || '{}'"
>
</div>
</div>
<div>
<div>Include "sub_jwk"</div>
<div>
<input
type="checkbox"
v-model="includeSubJwk"
>
</div>
</div>
<button @click="() => { signPromise = sign(); }">
Sign
</button>
<div v-if="signPromise">
<div>Signed JWT</div>
<Value :value="signPromise" />
</div>
</div>

<h2>Unpack and verify JWT</h2>
<div class="group">
<div>
<div>JWT to unpack</div>
<div>
<input
:value="jwt"
@input="jwt = $event.target.value || null"
>
</div>
</div>
<div>
<div>Signer address</div>
<div>
<input
:value="address"
@input="address = $event.target.value || null"
>
</div>
</div>
<button @click="() => { unpackPromise = unpack(); }">
Unpack
</button>
<div v-if="unpackPromise">
<div>Unpack result</div>
<Value :value="unpackPromise" />
</div>
</div>
</template>

<script>
import { mapState } from 'vuex';
import { unpackJwt, signJwt } from '@aeternity/aepp-sdk';
import Value from './components/Value.vue';
export default {
components: { Value },
computed: mapState(['aeSdk']),
data: () => ({
payloadAsJson: '{ "test": true }',
includeSubJwk: true,
signPromise: null,
jwt: 'eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzdWJfandrIjp7ImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiaEF5WFM1Y1dSM1pGUzZFWjJFN2NUV0JZcU43SksyN2NWNHF5MHd0TVFnQSJ9LCJ0ZXN0IjoiZGF0YSJ9.u9El4b2O2LRhvTTW3g46vk1hx0xXWPkJEaEeEy-rLzLr2yuQlNc7qIdcr_z06BgHx5jyYv2CpUL3hqLpc0RzBA',
address: null,
unpackPromise: null,
}),
methods: {
async sign() {
const payload = JSON.parse(this.payloadAsJson);
if (!this.includeSubJwk) payload.sub_jwk = undefined;
// TODO: expose account used in aepp-wallet connection
return signJwt(payload, this.aeSdk._resolveAccount(this.aeSdk.address));
},
async unpack() {
return unpackJwt(this.jwt, this.address);
},
},
};
</script>
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/index-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
126 changes: 126 additions & 0 deletions src/utils/jwt.ts
Original file line number Diff line number Diff line change
@@ -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<Jwt> {
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;
}
}
Loading

0 comments on commit c747ce6

Please sign in to comment.