Skip to content

Instantly share code, notes, and snippets.

@kitsune7
Created January 4, 2022 17:04
Show Gist options
  • Save kitsune7/c247a8ec3f334d0465ad966af8bb59d8 to your computer and use it in GitHub Desktop.
Save kitsune7/c247a8ec3f334d0465ad966af8bb59d8 to your computer and use it in GitHub Desktop.
A first attempt at a JavaScript/Typescript implementation for signing JWT in the browser based on a blog article
/* I put this gist together as a way to remember the work that I did to sign json web tokens
* in the browser without libraries like `jsonwebtoken` that require Node.js.
*
* This is unfortunately asynchronous, but there's a repository I just found that may do it
* synchronously in the browser: https://github.com/kjur/jsrsasign. I might refactor this
* after taking a look at that.
*
* This is based off of code from a blog post showing how to sign json web tokens in the
* browser: https://coolaj86.com/articles/sign-jwt-webcrypto-vanilla-js/
*/
import type { JWTHeader } from '@okta/okta-auth-js';
const EC = {
generate(): Promise<JsonWebKey> {
const keyType = {
name: 'ECDSA',
namedCurve: 'P-256',
};
const exportable = true;
const privileges: KeyUsage[] = ['sign', 'verify'];
return window.crypto.subtle
.generateKey(keyType, exportable, privileges)
.then(function (key) {
// returns an abstract and opaque WebCrypto object,
// which in most cases you'll want to export as JSON to be able to save
// @ts-ignore
return window.crypto.subtle.exportKey('jwk', key.privateKey) as JsonWebKey;
});
},
// Create a Public Key from a Private Key
//
// chops off the private parts
neuter(jwk): Promise<JsonWebKey> {
const copy = Object.assign({}, jwk);
delete copy.d;
copy.key_ops = ['verify'];
return copy;
},
};
const JWK = {
thumbprint(jwk): Promise<string> {
// lexigraphically sorted, no spaces
const sortedPub = '{"crv":"CRV","kty":"EC","x":"X","y":"Y"}'
.replace('CRV', jwk.crv)
.replace('X', jwk.x)
.replace('Y', jwk.y);
// The hash should match the size of the key,
// but we're only dealing with P-256
return window.crypto.subtle
.digest({ name: 'SHA-256' }, strToUint8(sortedPub))
.then(function (hash) {
return uint8ToUrlBase64(new Uint8Array(hash));
});
},
};
export const jwt = {
sign: async function (claims: Record<string, any>) {
const jwk = await EC.generate();
const kid = await JWK.thumbprint(jwk);
return jwt.signJwk(jwk, { kid }, claims);
},
signJwk(jwk: JsonWebKey, headers: Partial<JWTHeader>, claims: Record<string, any>) {
// Make a shallow copy of the key
// (to set ext if it wasn't already set)
jwk = Object.assign({}, jwk);
// The headers should probably be empty
headers.typ = 'JWT';
headers.alg = 'ES256';
if (!headers.kid) {
// alternate: see thumbprint function below
(headers as JWTHeader & { jwk: JsonWebKey }).jwk = {
kty: jwk.kty,
crv: jwk.crv,
x: jwk.x,
y: jwk.y,
};
}
let jws = {
// JWT "headers" really means JWS "protected headers"
protected: strToUrlBase64(JSON.stringify(headers)),
// JWT "claims" are really a JSON-defined JWS "payload"
payload: strToUrlBase64(JSON.stringify(claims)),
// Declaring upfront for Typescript
signature: '',
};
// To import as EC (ECDSA, P-256, SHA-256, ES256)
let keyType = {
name: 'ECDSA',
namedCurve: 'P-256',
hash: { name: 'SHA-256' },
};
// To make re-exportable as JSON (or DER/PEM)
let exportable = true;
// Import as a private key that isn't black-listed from signing
let privileges = ['sign'];
// Actually do the import, which comes out as an abstract key type
return (
window.crypto.subtle
// @ts-ignore
.importKey('jwk', jwk, keyType, exportable, privileges)
.then(function (privkey) {
// Convert UTF-8 to Uint8Array ArrayBuffer
let data = strToUint8(jws.protected + '.' + jws.payload);
// The signature and hash should match the bit-entropy of the key
// https://tools.ietf.org/html/rfc7518#section-3
let sigType = { name: 'ECDSA', hash: { name: 'SHA-256' } };
return window.crypto.subtle
.sign(sigType, privkey, data)
.then(function (signature) {
// returns an ArrayBuffer containing a JOSE (not X509) signature,
// which must be converted to Uint8 to be useful
jws.signature = uint8ToUrlBase64(new Uint8Array(signature));
// JWT is just a "compressed", "protected" JWS
return jws.protected + '.' + jws.payload + '.' + jws.signature;
});
})
);
},
};
// String (UCS-2) to Uint8Array
//
// because... JavaScript, Strings, and Buffers
function strToUint8(str) {
return new TextEncoder().encode(str);
}
function strToUrlBase64(str) {
return binToUrlBase64(utf8ToBinaryString(str));
}
// UCS-2 String to URL-Safe Base64
//
// btoa doesn't work on UTF-8 strings
function binToUrlBase64(bin) {
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+/g, '');
}
// UTF-8 to Binary String
//
// Because JavaScript has a strange relationship with strings
// https://coolaj86.com/articles/base64-unicode-utf-8-javascript-and-you/
function utf8ToBinaryString(str) {
const escstr = encodeURIComponent(str);
// replaces any uri escape sequence, such as %0A,
// with binary escape, such as 0x0A
return escstr.replace(/%([0-9A-F]{2})/g, function (match, p1) {
return String.fromCharCode(parseInt(p1, 16));
});
}
// Uint8Array to URL Safe Base64
//
// the shortest distant between two encodings... binary string
function uint8ToUrlBase64(uint8) {
let bin = '';
uint8.forEach(function (code) {
bin += String.fromCharCode(code);
});
return binToUrlBase64(bin);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment