Created
January 4, 2022 17:04
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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