Skip to content
This repository has been archived by the owner on Nov 14, 2024. It is now read-only.

Commit

Permalink
Merge pull request #5 from BitGo/decode-method
Browse files Browse the repository at this point in the history
feat: add decode method
  • Loading branch information
0xJacobV authored Mar 22, 2023
2 parents 51b904d + c6114c7 commit 2bc6764
Show file tree
Hide file tree
Showing 9 changed files with 236 additions and 46 deletions.
71 changes: 70 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,72 @@
# Identity Token

> Validates and decodes access tokens issued by Sign in with BitGo
Validates and decodes access tokens issued by Sign in with BitGo

## Installation

```bash
npm install @bitgo/identity-token
```

## Usage

### Decoding JWT

Decode a JWT payload synchronously and validate its schema. If schema does not
much, an error is thrown.

> Signature is not verified when decoding, this is useful in client applications since network calls are not made.
```typescript
import { decodeIdentityToken } from "@bitgo/identity-token";

const identityToken = decodeIdentityToken(bearerToken);

if (identityToken.isExpired()) {
throw new Error("Token is expired");
}

// shortcut properties
identityToken.userId;
identityToken.enterprises;

// entire jwt payload is also available
identityToken.payload;
```

### Verifying JWT

Verify a JWT signature was signed by BitGo and decode the JWT payload if verified.

> Backend services needing authorization should use this method.
```typescript
import {
getIdentityJWKSetFunction,
verifyIdentityToken,
} from "@bitgo/identity-token";

// fetches public certs from BitGo to verify signature when invoked
const identityJWKSetFunction = getIdentityJWKSetFunction();
let identityToken;
try {
identityToken = await verifyIdentityToken(
bearerToken,
identityJWKSetFunction
);
} catch (error) {
// token is either expired, failed to decode, or signature does not match
throw error;
}

// Example Usage
if (!identityToken.isOriginAllowed(req.header.origin)) {
throw new Error("Request origin is not allowed");
}

if (!identityToken.hasScope("required_scope")) {
throw new Error("Token does not contain required scope");
}
```


6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@
"build": "tsc -p 'tsconfig.build.json'",
"clean": "rm -rf -- dist",
"lint": "eslint src",
"test": "c8 mocha -r ts-node/register test/**/*.ts --exit"
"test": "c8 mocha -r ts-node/register test/**/*.ts --exit",
"prepublishOnly": "pnpm run build"
},
"dependencies": {
"fp-ts": "^2.10.5",
"io-ts": "2.1.3",
"io-ts-types": "^0.5.19",
"jose": "^4.11.2",
"jsonwebtoken": "^8.5.1",
"monocle-ts": "^2.3.13",
"newtype-ts": "^0.3.5",
"superagent": "^8.0.9"
},
"devDependencies": {
Expand Down
38 changes: 38 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 41 additions & 1 deletion src/token.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,52 @@
import type { IdentityJWTPayload, LegacyAccessToken } from './types';

export class IdentityToken {
public readonly userId: string;
public readonly enterprises: string[];
public readonly scopes: string[];
public readonly payload: IdentityJWTPayload;
private readonly token: string;

constructor(token: string, payload: IdentityJWTPayload) {
this.payload = payload;
this.token = token;
this.userId = payload.bitgo_id;
this.enterprises = payload.enterprises;
this.scopes = this.payload.scope.split(' ');
}

/**
* Determines if the token has expired.
* @returns boolean
*/
public isExpired() {
return new Date() >= this.parseEpoch(this.payload.exp);
}

/**
* Determines if the given request origin is registered with
* the requesting client.
*
* @param requestOrigin
* @returns boolean
*/
public isOriginAllowed(requestOrigin: string) {
const _origin = this.cleanOrigin(requestOrigin);
return this.payload['allowed-origins'].find((origin) =>
origin.includes(_origin)
)
? true
: false;
}

/**
* Determines if the identity token contains a given scope
*
* @param scope
* @returns boolean
*/
public hasScope(scope: string) {
return this.scopes.includes(scope);
}

/**
Expand All @@ -21,7 +61,7 @@ export class IdentityToken {
id: this.payload.jti || '',
client: this.payload.azp,
user: this.payload.bitgo_id,
scope: this.payload.scope.split(' '),
scope: this.scopes,
created: this.parseEpoch(this.payload.iat),
expires: this.parseEpoch(this.payload.exp),
origin: this._extractWebOrigin(
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as t from 'io-ts';
import * as jose from 'jose';
import { nonEmptyArray } from 'io-ts-types';

export type GetKeyFunction = ReturnType<typeof jose.createRemoteJWKSet>;

Expand All @@ -12,6 +13,11 @@ export const IdentityJWTPayload = t.type({
*/
bitgo_id: t.string,

/** Added to token payload via the bitgo-info token scope
* which maps the users enterprises as an user attribute.
*/
enterprises: nonEmptyArray(t.string),

/** Space seperated list of default and optional token scopes.
* e.g. 'openid bitgo-info wallet-view-all'
*/
Expand Down
25 changes: 19 additions & 6 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ export const getIdentityJWKSetFunction = (
);
};

/**
* Decodes a JWT and returns a Identity Token class object. If the JWT
* payload does not match the expected schema, a decoding error will
* be thrown.
*
* @param jwt
* @returns IdentityToken
*/
export const decodeIdentityToken = (jwt: string) => {
const payload = jose.decodeJwt(jwt);
const decodedPayload = IdentityJWTPayload.decode(payload);
if (isRight(decodedPayload)) {
return new IdentityToken(jwt, decodedPayload.right);
}
throw decodedPayload.left;
};

/**
* Verifies the signature of the JWT against the identity service JWS.
*
Expand All @@ -34,10 +51,6 @@ export const getIdentityJWKSetFunction = (
* @param jwks the jws get function to verify jwt signature
*/
export const verifyIdentityToken = async (jwt: string, jws: GetKeyFunction) => {
const { payload } = await jose.jwtVerify(jwt, jws);
const decodedPayload = IdentityJWTPayload.decode(payload);
if (isRight(decodedPayload)) {
return new IdentityToken(jwt, decodedPayload.right);
}
throw decodedPayload.left;
await jose.jwtVerify(jwt, jws);
return decodeIdentityToken(jwt);
};
35 changes: 35 additions & 0 deletions test/decode-token.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { assert } from 'chai';

import { decodeIdentityToken } from '../src';

describe('Decode Identity Token', () => {
it('should return an identity token given a valid jwt', async () => {
const bearerToken =
'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI1dTJqaHZQMGkyeU80a0lFUG96ejU5TW40RG4yR0VTc1hqTHg1Z2QxQmp3In0.eyJleHAiOjE4NTIyMTgyODcsImlhdCI6MTY3OTQxODI4NywiYXV0aF90aW1lIjoxNjc5NDE4Mjg3LCJqdGkiOiIwYzZkNzNlZC03MjhhLTQwMWYtOGJiOS05YWU0OWU5YWJmN2YiLCJpc3MiOiJodHRwczovL2lkZW50aXR5LmJpdGdvLWRldi5jb20vcmVhbG1zL2JpdGdvIiwic3ViIjoiZjoxMjY2ZWNmNy1kMzZiLTQ2NGEtOThiZi1kODBjZjE0YWZiNDg6ZXhwZXJpZW5jZSt0ZXN0LWFkbWluK2RvLW5vdC1kZWxldGVAYml0Z28uY29tIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiY29tLmJpdGdvLnRlc3R3ZWJhcHAiLCJzZXNzaW9uX3N0YXRlIjoiMDZhZDZkMTMtYWM5NS00MDY4LTkyNmItYzA3Y2UxOTQ3MDBjIiwiYWxsb3dlZC1vcmlnaW5zIjpbImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCJdLCJzY29wZSI6Im9wZW5pZCB1c2VyX21hbmFnZSB3YWxsZXRfc3BlbmRfYWxsIGJpdGdvLWluZm8gcHJvZmlsZSB3YWxsZXRfdmlld19hbGwgd2FsbGV0X2NyZWF0ZSIsInNpZCI6IjA2YWQ2ZDEzLWFjOTUtNDA2OC05MjZiLWMwN2NlMTk0NzAwYyIsImJpdGdvX2lkIjoiNWNhZTMxMzBmOGY0NTYxZDUxZGZjYmZkYWFmYmE5YjkiLCJuYW1lIjoiZXhwZXJpZW5jZSt0ZXN0LWFkbWluK2RvLW5vdC1kZWxldGUgYWRtaW4iLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJleHBlcmllbmNlK3Rlc3QtYWRtaW4rZG8tbm90LWRlbGV0ZUBiaXRnby5jb20iLCJnaXZlbl9uYW1lIjoiZXhwZXJpZW5jZSt0ZXN0LWFkbWluK2RvLW5vdC1kZWxldGUiLCJmYW1pbHlfbmFtZSI6ImFkbWluIiwiZW50ZXJwcmlzZXMiOlsiNWNhZTMxMzFmOGY0NTYxZDUxZGZjYzAwMTY2ODdiOTYiLCI1Y2FiZTNlOGExYjU2OTIzNTFjMzZmYzQ5ZmZkZDY4MCIsIjVjY2EwYzgxZTdlYTRhMzcwNWJlYjE3NDViNmEwNDJiIiwiNjFkZjNkZWUwMDA0NTgwMDA3M2QxMmU4NDRkYTk3ZGEiXX0.A1mmxX0_rXoPb5SEMRNE-zA5y44JYKRQlLN-Y8TSUkP8Yyo3RoA3QNr0351Da9TTNc73HpT2ahVwKoBPdT2z8unIvQ_Gsz-tHWmQyZ95HKt5Lja82lJvS0K2aRhCcTSF1Zw3AGLeaMesl7umMQLkIf5s4aN380Tyx1FeJReVF8dM1_bAvRzrffZQSOUFACU2Qd4LJ2JYaPrIrPLZkDOJ0vQzfBCOsRox-Y6m29oQ6Lw8-hbuN1gtk-DUkMX8AdWto4f74T0d0mKIN929-GYmmriieuqnrk5HqZ7blYrF3GB6jF8-eD5GJe3nhLJAEZ9OJVzKJS6fGyL6zgK-HyZsPg';

const identityToken = decodeIdentityToken(bearerToken);

assert.isDefined(identityToken);
assert.isNotEmpty(identityToken?.payload);
});

it('should throw an error given a valid jwt but with an invalid payload schema', async () => {
const bearerToken =
'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.NHVaYe26MbtOYhSKkoKYdFVomg4i8ZJd8_-RU8VNbftc4TSMb4bXP3l3YlNWACwyXPGffz5aXHc6lty1Y2t4SWRqGteragsVdZufDn5BlnJl9pdR_kdVFUsra2rWKEofkZeIC4yWytE58sMIihvo9H1ScmmVwBcQP6XETqYd0aSHp1gOa9RdUPDvoXQ5oqygTqVtxaDr6wUFKrKItgBMzWIdNZ6y7O9E0DhEPTbE9rfBo6KTFsHAZnMg4k68CDp2woYIaXbmYTWcvbzIuHO7_37GT79XdIwkm95QJ7hYC9RiwrV7mesbY4PAahERJawntho0my942XheVLmGwLMBkQ';

try {
decodeIdentityToken(bearerToken);
assert.fail();
} catch (err) {}
});

it('should throw an error given an invalid jwt', async () => {
const bearerToken =
'v2x0b75a97dd8caf93b94c0739c8f66478f841217f4ddad7a3cf2e68d2e6a8c5805';

try {
decodeIdentityToken(bearerToken);
assert.fail();
} catch (err) {}
});
});
30 changes: 21 additions & 9 deletions test/identity-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,8 @@ describe('Identity Token', () => {
let identityToken: IdentityToken;

before(async () => {
// Expires Wed Aug 9th 2028
const bearerToken =
'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI1dTJqaHZQMGkyeU80a0lFUG96ejU5TW40RG4yR0VTc1hqTHg1Z2QxQmp3In0.eyJleHAiOjE4NDk0NDM5MTQsImlhdCI6MTY3NjY0MzkxNCwianRpIjoiOGNiNWNjMTktNGIyNi00MzY5LTk0MmYtNTg4NzhhYjA1YTYzIiwiaXNzIjoiaHR0cHM6Ly9pZGVudGl0eS5iaXRnby1kZXYuY29tL3JlYWxtcy9iaXRnbyIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiJmOjEyNjZlY2Y3LWQzNmItNDY0YS05OGJmLWQ4MGNmMTRhZmI0ODpleHBlcmllbmNlK3Rlc3QtYWRtaW4rZG8tbm90LWRlbGV0ZUBiaXRnby5jb20iLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJjb20uYml0Z28uY2xpIiwic2Vzc2lvbl9zdGF0ZSI6ImE5ZDc4OTY4LTY0ODMtNGM2Ni05NWNiLWQ3YzM3MDcyYjc1MCIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiY2hyb21lLWV4dGVuc2lvbjovL2twY29qaGdkaG5qbW1lZ2hpYmxwamVpY2Jrb2VsYm1mIiwiaHR0cDovL2xvY2FsaG9zdCJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoiYml0Z28taW5mbyB3YWxsZXRfc3BlbmQgZW1haWwgcHJvZmlsZSB3YWxsZXRfdmlldyB3YWxsZXRfY3JlYXRlIiwic2lkIjoiYTlkNzg5NjgtNjQ4My00YzY2LTk1Y2ItZDdjMzcwNzJiNzUwIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImJpdGdvX2lkIjoiNWNhZTMxMzBmOGY0NTYxZDUxZGZjYmZkYWFmYmE5YjkiLCJuYW1lIjoiZXhwZXJpZW5jZSt0ZXN0LWFkbWluK2RvLW5vdC1kZWxldGUgYWRtaW4iLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJleHBlcmllbmNlK3Rlc3QtYWRtaW4rZG8tbm90LWRlbGV0ZUBiaXRnby5jb20iLCJnaXZlbl9uYW1lIjoiZXhwZXJpZW5jZSt0ZXN0LWFkbWluK2RvLW5vdC1kZWxldGUiLCJmYW1pbHlfbmFtZSI6ImFkbWluIiwiZW1haWwiOiJleHBlcmllbmNlK3Rlc3QtYWRtaW4rZG8tbm90LWRlbGV0ZUBiaXRnby5jb20ifQ.Oq_txq17ApirE6-o_RRBhjhvJmGW6NAa6G9Km7WpxQoJV0-8yN1ddSnQ5W3UljM4ArsQwwitG9NvTKxm1YuwZ-e7vzcOmtnmMbsC_DKGO3PyatG4ndQmAHw4XAw9eYKf8lVl_Mk_mJf45mbOOJ_zXM8SKBruHPJa1LqJxeMrWmuZKssPvuvB76UnqPNmdx0F-iiQE9Rs7_7y3OKFtaBrSG8K6euzx3AY8P7wkK-z6Wlfelz5hh9AAZ81tjlNwii5ZEUAMygxjPQWp9VD9wo4D7LEM51Ad4y4-vUQmZHAXyGPphQHcODNgQxJa_Uly_tOQGtdqRdodldwFZcGOxQX3Q';

'eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI1dTJqaHZQMGkyeU80a0lFUG96ejU5TW40RG4yR0VTc1hqTHg1Z2QxQmp3In0.eyJleHAiOjE4NTIyMjA2OTUsImlhdCI6MTY3OTQyMDY5NSwianRpIjoiYTZlMTJhNzktM2QyZC00Mzc2LWE3MTAtYWVlZDQxMjkyN2RhIiwiaXNzIjoiaHR0cHM6Ly9pZGVudGl0eS5iaXRnby1kZXYuY29tL3JlYWxtcy9iaXRnbyIsInN1YiI6ImY6MTI2NmVjZjctZDM2Yi00NjRhLTk4YmYtZDgwY2YxNGFmYjQ4OmV4cGVyaWVuY2UrdGVzdC1hZG1pbitkby1ub3QtZGVsZXRlQGJpdGdvLmNvbSIsInR5cCI6IkJlYXJlciIsImF6cCI6ImNvbS5iaXRnby5jbGkiLCJzZXNzaW9uX3N0YXRlIjoiNjRmOThiMTQtMTM5Zi00ZmRjLWEyYzItZGJkN2QzZjljZjAyIiwiYWxsb3dlZC1vcmlnaW5zIjpbImNocm9tZS1leHRlbnNpb246Ly9rcGNvamhnZGhuam1tZWdoaWJscGplaWNia29lbGJtZiIsImh0dHA6Ly9sb2NhbGhvc3QiXSwic2NvcGUiOiJ1c2VyX21hbmFnZSB3YWxsZXRfc3BlbmRfYWxsIGJpdGdvLWluZm8gcHJvZmlsZSB3YWxsZXRfdmlld19hbGwgd2FsbGV0X2NyZWF0ZSIsInNpZCI6IjY0Zjk4YjE0LTEzOWYtNGZkYy1hMmMyLWRiZDdkM2Y5Y2YwMiIsImJpdGdvX2lkIjoiNWNhZTMxMzBmOGY0NTYxZDUxZGZjYmZkYWFmYmE5YjkiLCJuYW1lIjoiZXhwZXJpZW5jZSt0ZXN0LWFkbWluK2RvLW5vdC1kZWxldGUgYWRtaW4iLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJleHBlcmllbmNlK3Rlc3QtYWRtaW4rZG8tbm90LWRlbGV0ZUBiaXRnby5jb20iLCJnaXZlbl9uYW1lIjoiZXhwZXJpZW5jZSt0ZXN0LWFkbWluK2RvLW5vdC1kZWxldGUiLCJmYW1pbHlfbmFtZSI6ImFkbWluIiwiZW50ZXJwcmlzZXMiOlsiNWNhZTMxMzFmOGY0NTYxZDUxZGZjYzAwMTY2ODdiOTYiLCI1Y2FiZTNlOGExYjU2OTIzNTFjMzZmYzQ5ZmZkZDY4MCIsIjVjY2EwYzgxZTdlYTRhMzcwNWJlYjE3NDViNmEwNDJiIiwiNjFkZjNkZWUwMDA0NTgwMDA3M2QxMmU4NDRkYTk3ZGEiXX0.BjgEc9Ou1j0A4X-78l_SkBnGUbIzPmh5j0_777olZqjcTnmNSaOqp3bhfqBVnNhcf4DedaGEDwE5D0O3FzfgO-r0MUekwmr_hEvLkctOGM9CfQEEW1e_JTtX8csG7-JdQYy_iIcv81zd6gHgscsvUCI0fFQNlrknFRNCwiU5lqEkIBBAxobuM6H37mREVFudn6vtWwhPVSb4ZHPgRPDXUM-16rfywBGIWBlZnBZKTp1pI0_yuWiHDdL1lrDz7j7IBsDncKPkT4wwjI-jgoZM5uxY9gfBiY6zbIdPk9r2zj75vqm9maz0cA4_PCln4L90XQc4TnOlteffGcd6eToeEw';
nockGetJWKSetCall();

const identityJWKSetFunction = getIdentityJWKSetFunction();
Expand All @@ -38,12 +36,6 @@ describe('Identity Token', () => {

it('should map to legacy access token using selected web origin', async () => {
const accessToken = identityToken.mapToLegacy('http://localhost:3000');

assert.equal(accessToken.client, 'com.bitgo.cli');
assert.equal(accessToken.user, '5cae3130f8f4561d51dfcbfdaafba9b9');
assert.equal(accessToken.label, 'identity-session');

// harbor dev extension
assert.equal(accessToken.origin, 'localhost');
});

Expand All @@ -61,4 +53,24 @@ describe('Identity Token', () => {
const origin = identityToken._extractWebOrigin([]);
assert.equal(origin, '');
});

it('should check token expiration', () => {
const isExpired = identityToken.isExpired();
assert.equal(isExpired, false);
});

it('should return true for a registred web origin', () => {
const isAllowed = identityToken.isOriginAllowed('http://localhost:3000');
assert.equal(isAllowed, true);
});

it('should return false for a non registred web origin', () => {
const isAllowed = identityToken.isOriginAllowed('https://evil.com');
assert.equal(isAllowed, false);
});

it('should return true given a registered token scope', () => {
const hasScope = identityToken.hasScope('wallet_view_all');
assert.equal(hasScope, true);
});
});
Loading

0 comments on commit 2bc6764

Please sign in to comment.