diff --git a/package.json b/package.json index 1701f29f..09995e4b 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,8 @@ }, "dependencies": { "base-64": "^0.1.0", - "jsrsasign": "8.0.12", + "crypto-js": "^3.1.9-1", + "jsbn": "^1.1.0", "jwt-decode": "^2.2.0", "url": "^0.11.0" }, diff --git a/src/jwt/__tests__/base64.spec.js b/src/jwt/__tests__/base64.spec.js new file mode 100644 index 00000000..af409bef --- /dev/null +++ b/src/jwt/__tests__/base64.spec.js @@ -0,0 +1,31 @@ +import * as base64 from '../base64'; + +describe('helpers base64 url', function() { + describe('padding', function() { + it('does not add to multiple of 4', function() { + expect(base64.padding('')).toBe(''); + expect(base64.padding('abcd')).toBe('abcd'); + }); + it('adds to non multiple of 4', function() { + expect(base64.padding('a')).toBe('a==='); + expect(base64.padding('ab')).toBe('ab=='); + expect(base64.padding('abc')).toBe('abc='); + expect(base64.padding('abced')).toBe('abced==='); + }); + it('does not change already padded value', function() { + const padded = base64.padding('abc'); + expect(padded).toBe('abc='); + const again = base64.padding(padded); + expect(again).toBe('abc='); + }); + }); + + describe('decoding to hex', function() { + it('should convert base64 input into hex output', function() { + expect(base64.decodeToHEX('AQAB')).toBe('010001'); + expect(base64.decodeToHEX('uGbXWiK3dQTyCbX5')).toBe( + 'b866d75a22b77504f209b5f9', + ); + }); + }); +}); diff --git a/src/jwt/__tests__/jwks.json b/src/jwt/__tests__/jwks.json new file mode 100644 index 00000000..42e1df18 --- /dev/null +++ b/src/jwt/__tests__/jwks.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "use": "sig", + "alg": "RS256", + "kty": "RSA", + "e": "AQAB", + "kid": "1234", + "n": "uGbXWiK3dQTyCbX5xdE4yCuYp0AF2d15Qq1JSXT_lx8CEcXb9RbDddl8jGDv-spi5qPa8qEHiK7FwV2KpRE983wGPnYsAm9BxLFb4YrLYcDFOIGULuk2FtrPS512Qea1bXASuvYXEpQNpGbnTGVsWXI9C-yjHztqyL2h8P6mlThPY9E9ue2fCqdgixfTFIF9Dm4SLHbphUS2iw7w1JgT69s7of9-I9l5lsJ9cozf1rxrXX4V1u_SotUuNB3Fp8oB4C1fLBEhSlMcUJirz1E8AziMCxS-VrRPDM-zfvpIJg3JljAh3PJHDiLu902v9w-Iplu1WyoB2aPfitxEhRN0Yw" + } + ] +} diff --git a/src/jwt/__tests__/jwt.spec.js b/src/jwt/__tests__/jwt.spec.js index ffe854af..c06948fa 100644 --- a/src/jwt/__tests__/jwt.spec.js +++ b/src/jwt/__tests__/jwt.spec.js @@ -1,7 +1,6 @@ import verifyToken from '../index'; import * as signatureVerifier from '../signatureVerifier'; const jwtDecoder = require('jwt-decode'); -import {KEYUTIL} from 'jsrsasign'; import * as fs from 'fs'; import * as path from 'path'; import fetchMock from 'fetch-mock'; @@ -12,16 +11,6 @@ describe('id token verification tests', () => { fetchMock.restore(); }); - it('uses fixed version of jsrsasign', () => { - // jsrsasign has not been updated recently; we want to verify that the dependency is pinned to 8.0.12 - const packageData = fs.readFileSync( - path.resolve(__dirname, '../../../package.json'), - ); - const packageJson = JSON.parse(packageData); - const jsrsasignDepVersion = packageJson.dependencies.jsrsasign; - expect(jsrsasignDepVersion).toBe('8.0.12'); - }); - it('resolves when no idToken present', async () => { await expect(verify(undefined)).resolves.toBeUndefined(); }); @@ -70,7 +59,7 @@ describe('id token verification tests', () => { const testJwt = 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMzQifQ.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTcwMjAyOTMxLCJpYXQiOjE1NzAwMzAxMzEsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NzAxMTY1MzAuNzk2fQ.Xad-J3PtImY3z--Gvj-H61tH18mCGQUUBkcug-CB5ehkjd56PXrA-AJHZK7OLryB_uj6sFKVn-V8Wr6t3KW7_Fd2n-__Ca2h6PtgIrjceZlHAQY4SgAk9tPmeeTOhs6KyXDeW0Ot0j3CP9p7nWxgCGMu_H5J5ZgJSVUVlffVpaIMEGiFZ_r71PLPtuTL3GsDwtICG_5xuqoR2YBLSpNuuc46t15i94E3JC1UXGryRfxVbeHg3x5DF9nf6eVkMHRdi-CdNQn2iD0G9OmxxELh-40pecbyUxLv4NfTHmbxOdvWRK00N8sgkElnPnoWXb5pacxLShFsBTJdXIsyqF_onA'; - const jwks = getJwks(); + const jwks = getExpectedJwks(); jwks.keys[0].kid = '4321'; setupFetchMock({jwks}); @@ -86,14 +75,31 @@ describe('id token verification tests', () => { ); }); - it('fails when signature is not verified', async () => { + it('fails when public key is invalid and cannot be reconstructed', async () => { const testJwt = 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMzQifQ.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTcwMjAzMjgxLCJpYXQiOjE1NzAwMzA0ODEsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NzAxMTY4ODAuNjk0fQ.ZNPsQq_U8NGyi5WFNgvuT0QlxfGFS9w6YIHWiF4dnwz_Zf3mv3gh4wybDR8vaLCE8ONTXvT9V_rW6oqNHSvEwa0nvPy2Vi3gVAvSfusoiYhkuQG_6SuqbeOrNJ1cejGzqw_iv2s6yEyN3B9wp0TCuIKL5jLPttaRi6ouGCbYeReANecaLOVZstrO4GhlY0NwtT4j5Dn1tDYavWxi1DZBisxBvMEFA6N0aQa51gJm6RYtUjBTo50j1xG5b7TIF4edjjT85FYQgrwEzA7Ss3HpnrYXEEvHn4nCsc585T3GKQuF21Nli-qGgQ3MywPOOqqiCSvL254Cp88Gt3xDS1hnqg'; - const jwks = getJwks(); - jwks.keys[0].n += 'bad'; + const jwks = getExpectedJwks(); + jwks.keys[0].n = 'bad-modulus'; setupFetchMock({jwks}); + const result = verify(testJwt); + expect(result).rejects.toHaveProperty( + 'name', + 'a0.idtoken.invalid_signature', + ); + expect(result).rejects.toHaveProperty( + 'message', + 'Invalid ID token signature', + ); + }); + + it('fails when signature is invalid', async () => { + const testJwt = + 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMzQifQ.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTcwMjAzMjgxLCJpYXQiOjE1NzAwMzA0ODEsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NzAxMTY4ODAuNjk0fQ.invalid-signature'; + + setupFetchMock(); + await expect(verify(testJwt)).rejects.toHaveProperty( 'name', 'a0.idtoken.invalid_signature', @@ -114,53 +120,11 @@ describe('id token verification tests', () => { it('passes verification with valid token signed with RS256', async () => { const testJwt = 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMzQifQ.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJpYXQiOjE1NjczMTQwMDAsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NjczMTQwMDB9.ObH7oG3NsGaxWnB8rzbLOgAD2I0fr9dyZC81YUrbju3RwC3lRAxqJkbesiSdGKry9OamIhKYwUGpPK0wrBaRJo8UjDjICkhM6lGP23plysemxhDnFK1qjj-NaUaW1yKg14v2lVpQl7glW9LIhFDhpqIf4bILA2wt9-z8Uvi31ETZvGb8PDY2bEvjXR-69-yLuoTNT2skP9loKfz6hHDMQCTWrGA61BMMjkZBLo9UotD9BzN8V7bLrFFT25v6q9N83mWaGLsHntzPIl3EYPOwX0NbE0lXKar59TUqtaTB3uNFHbGjIYi8wuuIp4PV9arpE3YrjWOOmrMurD1KpIyQrQ'; - const contents = fs.readFileSync( - path.resolve(__dirname, './pubkey.pem'), - 'utf8', - ); - const pubKey = KEYUTIL.getKey(contents); - const jwkFromKey = KEYUTIL.getJWKFromKey(pubKey); - - jwkFromKey.kid = '1234'; - jwkFromKey.alg = 'RS256'; - jwkFromKey.use = 'sig'; - - const jwks = { - keys: [jwkFromKey], - }; - - setupFetchMock({jwks}); + setupFetchMock(); await expect(verify(testJwt)).resolves.toBeUndefined(); }); - - const setupFetchMock = ({ - domain = BASE_EXPECTATIONS.domain, - jwks = getJwks(), - } = {}) => { - const expectedDiscoveryUri = `https://${domain}/.well-known/openid-configuration`; - const expectedJwksUri = `https://${domain}/.well-known/jwks.json`; - - fetchMock.get(expectedDiscoveryUri, {jwks_uri: expectedJwksUri}); - fetchMock.get(expectedJwksUri, jwks); - }; - - const getJwks = () => { - return { - keys: [ - { - kty: 'RSA', - n: - 'st69ml_DI8MhepFSV9o8zjzRFiEst1_1-XJe0ib-g_aMauGTFOqeITdVqWTJMzZsjtwsPFD1CXbmEtI282GBbniJ7XkrZwpjzXangbvJpFE-aBmKeogTq6B94a19H9umCtV7eC55xDmOylXYPFdcVFvolWajdYGywqH8d4Cu_pIB25ELoA78goP4MqweJhnOt4r5jORea2paLXa04ojvglbOGnFec65Y4Hyw2mWGu06f0sxW-LMGzwP_SgbpRDKKnn-W8grguPq63sLexDTBFLyPNCcFQ8wnEQzLCaNNJItu-OFwgwgJhiB3d0et5m3lF2_lEJ2Pwndp0ORlOWcJbQbad', - e: 'AQAB', - kid: '1234', - alg: 'RS256', - use: 'sig', - }, - ], - }; - }; }); describe('token claims verification', () => { @@ -639,4 +603,21 @@ describe('id token verification tests', () => { const options = Object.assign({}, optionsDefaults, optionsOverrides); return verifyToken(idToken, options); }; + + const setupFetchMock = ({ + domain = BASE_EXPECTATIONS.domain, + jwks = getExpectedJwks(), + } = {}) => { + const expectedDiscoveryUri = `https://${domain}/.well-known/openid-configuration`; + const expectedJwksUri = `https://${domain}/.well-known/jwks.json`; + + fetchMock.get(expectedDiscoveryUri, {jwks_uri: expectedJwksUri}); + fetchMock.get(expectedJwksUri, jwks); + }; + + const getExpectedJwks = () => { + return JSON.parse( + fs.readFileSync(path.resolve(__dirname, './jwks.json'), 'utf8'), + ); + }; }); diff --git a/src/jwt/base64.js b/src/jwt/base64.js new file mode 100644 index 00000000..0fb5488c --- /dev/null +++ b/src/jwt/base64.js @@ -0,0 +1,32 @@ +/** + * Borrowed from IDToken-verifier package + * https://github.com/auth0/idtoken-verifier/blob/master/src/helpers/base64.js + */ +import base64 from 'base64-js'; + +export function padding(str) { + const paddingLength = 4; + const mod = str.length % paddingLength; + const pad = paddingLength - mod; + + if (mod === 0) { + return str; + } + + return str + new Array(1 + pad).join('='); +} + +function byteArrayToHex(raw) { + let HEX = ''; + + for (let i = 0; i < raw.length; i++) { + const _hex = raw[i].toString(16); + HEX += _hex.length === 2 ? _hex : '0' + _hex; + } + + return HEX; +} + +export function decodeToHEX(str) { + return byteArrayToHex(base64.toByteArray(padding(str))); +} diff --git a/src/jwt/rsa-verifier.js b/src/jwt/rsa-verifier.js new file mode 100644 index 00000000..3cb7303d --- /dev/null +++ b/src/jwt/rsa-verifier.js @@ -0,0 +1,72 @@ +/* +Based on the work of Tom Wu +http://www-cs-students.stanford.edu/~tjw/jsbn/ +http://www-cs-students.stanford.edu/~tjw/jsbn/LICENSE +*/ + +import {BigInteger} from 'jsbn'; +import SHA256 from 'crypto-js/sha256'; + +const digestInfoHead = { + sha256: '3031300d060960864801650304020105000420', +}; + +const digestAlgs = { + sha256: SHA256, +}; + +function RSAVerifier(modulus, exp) { + this.n = null; + this.e = 0; + + if (modulus && modulus.length > 0 && exp && exp.length > 0) { + this.n = new BigInteger(modulus, 16); + this.e = parseInt(exp, 16); + } else { + throw new Error('Invalid key data'); + } +} + +function getAlgorithmFromDigest(hDigestInfo) { + for (let algName in digestInfoHead) { + const head = digestInfoHead[algName]; + const len = head.length; + + if (hDigestInfo.substring(0, len) === head) { + return { + alg: algName, + hash: hDigestInfo.substring(len), + }; + } + } + return []; +} + +RSAVerifier.prototype.verify = function(msg, encodedSignature) { + const decodedSignature = encodedSignature.replace(/[^0-9a-f]|[\s\n]]/gi, ''); + + const signature = new BigInteger(decodedSignature, 16); + if (signature.bitLength() > this.n.bitLength()) { + //Signature does not match with the key modulus. + return false; + } + + const decryptedSignature = signature.modPowInt(this.e, this.n); + const digest = decryptedSignature.toString(16).replace(/^1f+00/, ''); + + const digestInfo = getAlgorithmFromDigest(digest); + if (digestInfo.length === 0) { + //Hashing algorithm is not found + return false; + } + + if (!digestAlgs.hasOwnProperty(digestInfo.alg)) { + //Hashing algorithm is not supported + return false; + } + + const msgHash = digestAlgs[digestInfo.alg](msg).toString(); + return digestInfo.hash === msgHash; +}; + +export default RSAVerifier; diff --git a/src/jwt/signatureVerifier.js b/src/jwt/signatureVerifier.js index df653af3..94b6e238 100644 --- a/src/jwt/signatureVerifier.js +++ b/src/jwt/signatureVerifier.js @@ -1,5 +1,6 @@ import AuthError from '../auth/authError'; -import {KEYUTIL, KJUR} from 'jsrsasign'; +import RSAVerifier from './rsa-verifier'; +import * as base64 from './base64'; const jwtDecoder = require('jwt-decode'); const ALLOWED_ALGORITHMS = ['RS256', 'HS256']; @@ -45,22 +46,28 @@ export const verifySignature = (idToken, options) => { } return getJwk(options.domain, header.kid).then(jwk => { - const pubKey = KEYUTIL.getKey(jwk); - const signatureValid = KJUR.jws.JWS.verify(idToken, pubKey, ['RS256']); - - if (signatureValid) { + const rsaVerifier = rsaVerifierForKey(jwk); + const encodedParts = idToken.split('.'); + const headerAndPayload = encodedParts[0] + '.' + encodedParts[1]; + const signature = base64.decodeToHEX(encodedParts[2]); + if (rsaVerifier.verify(headerAndPayload, signature)) { return Promise.resolve(payload); } - return Promise.reject( idTokenError({ error: 'invalid_signature', - desc: 'Invalid token signature', + desc: 'Invalid ID token signature', }), ); }); }; +const rsaVerifierForKey = jwk => { + const modulus = base64.decodeToHEX(jwk.n); + const exponent = base64.decodeToHEX(jwk.e); + return new RSAVerifier(modulus, exponent); +}; + const getJwk = (domain, kid) => { return getJwksUri(domain) .then(uri => fetchJson(uri)) diff --git a/yarn.lock b/yarn.lock index 53d2170a..8fecd794 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1952,6 +1952,11 @@ cross-spawn@^6.0.0: shebang-command "^1.2.0" which "^1.2.9" +crypto-js@^3.1.9-1: + version "3.1.9-1" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-3.1.9-1.tgz#fda19e761fc077e01ffbfdc6e9fdfc59e8806cd8" + integrity sha1-/aGedh/Ad+Af+/3G6f38WeiAbNg= + cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0": version "0.3.8" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" @@ -3621,6 +3626,11 @@ js2xmlparser@^4.0.0: dependencies: xmlcreate "^2.0.0" +jsbn@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040" + integrity sha1-sBMHyym2GKHtJux56RH4A8TaAEA= + jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" @@ -3796,11 +3806,6 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" -jsrsasign@8.0.12: - version "8.0.12" - resolved "https://registry.yarnpkg.com/jsrsasign/-/jsrsasign-8.0.12.tgz#22abb9656d34a30b9530436720835e89c2e5c316" - integrity sha1-Iqu5ZW00owuVMENnIINeicLlwxY= - jwt-decode@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79"