embassy
Version:
Simple JSON Web Tokens (JWT) with embedded scopes for services
418 lines (413 loc) • 16.1 kB
text/typescript
/*
* Embassy
* Copyright (c) 2017-2021 Tom Shawver
*/
import {
JsonWebTokenError,
KeyNotFoundError,
ScopeNotFoundError,
Token,
TokenOptions,
TokenExpiredError,
TokenParseError
} from '../src'
import { pub, priv } from './fixtures/keys'
import delay from 'delay'
const testTokenStr =
'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Imdvb2RLZXkifQ.eyJhdWQiOiJ0ZX' +
'N0LWF1ZGllbmNlIiwiaXNzIjoidGVzdC1pc3N1ZXIiLCJzdWIiOiJiYXIiLCJwcm0iOiIiLCJ' +
'pYXQiOjE0NjM4ODk0ODYsImV4cCI6MTQ2Mzg4OTQ5MX0.W6Ulky7iGnCp9OGtbVzm_Bdz-FOO' +
'dL_2UFuaixsPE8FBQxtByG4nBNrUOcaT6_qJ7_tHZNqFfICQM24NzqxxgQ'
const domainScopes = {
foo: { bar: 0, baz: 1 },
app: { bap: 0 }
}
const tokenOpts: TokenOptions = {
domainScopes,
keys: {
goodKey: { publicKey: pub, privateKey: priv, algorithm: 'ES256' },
privAlgo: { privateKey: priv, algorithm: 'ES256' },
corruptKey: {
publicKey: pub,
privateKey: priv.replace(/M/g, '@'),
algorithm: 'ES256'
},
hmac: {
privateKey: 'secret',
algorithm: 'HS256'
}
}
}
let inst: Token
describe('Token', () => {
beforeEach(() => {
inst = new Token(tokenOpts)
})
describe('constructor', () => {
it('instantiates an empty token', () => {
inst = new Token()
expect(inst).toBeInstanceOf(Token)
})
it('instantiates a token with claims', () => {
inst = new Token({ claims: { email: 'foo@bar.com' } })
expect(inst.claims).toEqual(
expect.objectContaining({ email: 'foo@bar.com' })
)
})
it('instantiates a token from a token string', () => {
inst = new Token({ token: testTokenStr })
expect(inst.claims).toEqual(
expect.objectContaining({ iss: 'test-issuer' })
)
})
it('throws a TokenParseError if a bad token is passed in', () => {
const getInst = (opts: TokenOptions) => () => new Token(opts)
expect(getInst({ token: 'foo' })).toThrowError(TokenParseError)
})
})
describe('options', () => {
it('returns undefined for unset options', () => {
expect(inst.getOption('lead', 'foo')).toBeUndefined()
})
it('sets and retrieves options', () => {
inst.setOption('lead', 'foo', 5)
inst.setOption('lead', 'bar', 'baz')
expect(inst.getOption('lead', 'foo')).toEqual(5)
expect(inst.getOption('lead', 'bar')).toEqual('baz')
})
})
describe('scopes', () => {
it('denies scope on nonexistent scope byte', async () => {
await expect(inst.hasScope('foo', 'bar')).resolves.toBe(false)
})
it('denies scope on existing scope byte with unset bit', async () => {
await inst.grantScope('foo', 'baz')
await expect(inst.hasScope('foo', 'bar')).resolves.toBe(false)
})
it('has scope after successful grant', async () => {
await inst.grantScope('foo', 'bar')
await expect(inst.hasScope('foo', 'bar')).resolves.toBe(true)
})
it('denies scope after a revoke', async () => {
await inst.grantScope('foo', 'bar')
await inst.revokeScope('foo', 'bar')
await expect(inst.hasScope('foo', 'bar')).resolves.toBe(false)
})
it('has no effect when revoking an unset scope', async () => {
await inst.revokeScope('foo', 'bar')
await expect(inst.hasScope('foo', 'bar')).resolves.toBe(false)
})
it('grants and checks multiple scopes', async () => {
const map = { foo: ['bar', 'baz'] }
await inst.grantScopes(map)
await expect(inst.hasScopes(map)).resolves.toBe(true)
})
it('grants and revokes multiple scopes', async () => {
const map = { foo: ['bar', 'baz'] }
await inst.grantScopes(map)
await inst.revokeScopes(map)
await expect(inst.hasScopes(map)).resolves.toBe(false)
})
it('fails to grant unknown scopes in a known domain', async () => {
await expect(inst.grantScope('foo', 'foo')).rejects.toThrowError(
ScopeNotFoundError
)
})
it('fails to grant scopes for unknown domains', async () => {
await expect(inst.grantScope('bar', 'bar')).rejects.toThrowError(
ScopeNotFoundError
)
})
it('calls a function to refresh scopes via promise on grant', async () => {
const refreshScopes = jest.fn().mockResolvedValue(domainScopes)
inst = new Token({ refreshScopes })
await inst.grantScope('foo', 'bar')
expect(refreshScopes).toBeCalled()
})
it('calls a function to refresh scopes on grant', async () => {
const refreshScopes = jest.fn().mockResolvedValue(domainScopes)
inst = new Token({ refreshScopes })
await inst.grantScope('foo', 'bar')
expect(refreshScopes).toBeCalled()
})
it('calls a function to refresh scopes on revoke', async () => {
const refreshScopes = jest.fn().mockResolvedValue(domainScopes)
inst = new Token({ refreshScopes })
await inst.revokeScope('foo', 'bar')
expect(refreshScopes).toBeCalled()
})
it('calls a function to refresh scopes via promise on check', async () => {
const refreshScopes = jest.fn().mockResolvedValue(domainScopes)
inst = new Token({ refreshScopes })
await inst.hasScope('foo', 'bar')
expect(refreshScopes).toBeCalled()
})
it('stores results of last scope refresh for future calls', async () => {
const refreshScopes = jest.fn().mockResolvedValue(domainScopes)
inst = new Token({ refreshScopes })
await inst.hasScope('foo', 'bar')
expect(refreshScopes).toBeCalledTimes(1)
await inst.hasScope('foo', 'baz')
expect(refreshScopes).toBeCalledTimes(1)
})
it('does not refresh scopes within refreshScopesAfterMs', async () => {
let calls = 0
const refreshScopes = () => {
if (!calls++) return domainScopes
return { bar: { baz: 2 } }
}
inst = new Token({ refreshScopes })
await inst.hasScope('foo', 'bar')
await expect(inst.hasScope('bar', 'baz')).rejects.toThrowError(
ScopeNotFoundError
)
})
it('refreshes scopes outside of refreshScopesAfterMs', async () => {
let calls = 0
const refreshScopes = () => {
if (!calls++) return domainScopes
return { bar: { baz: 2 } }
}
inst = new Token({ refreshScopes, refreshScopesAfterMs: 10 })
await inst.hasScope('foo', 'bar')
await delay(11)
await expect(inst.hasScope('bar', 'baz')).resolves.toBe(false)
})
it('grants, checks, and revokes scopes in combined format', async () => {
const scopes = ['foo|bar', 'foo|baz', 'bap']
await inst.grantScopes(scopes)
await expect(inst.hasScopes(scopes)).resolves.toBe(true)
await inst.revokeScopes(scopes)
await expect(inst.hasScopes(scopes)).resolves.toBe(false)
})
it('can detect a revoked scope in combined format', async () => {
const scopes = ['foo|bar', 'foo|baz', 'bap']
await inst.grantScopes(scopes)
await inst.revokeScope('foo|baz')
await expect(inst.hasScopes(scopes)).resolves.toBe(false)
})
it('grants and checks a single scope in combined format', async () => {
await inst.grantScope('foo|bar')
await expect(inst.hasScope('foo|bar')).resolves.toBe(true)
})
})
describe('sign', () => {
it('signs a token', async () => {
const signed = await inst.sign('goodKey', { subject: 'foo' })
expect(typeof signed).toBe('string')
expect(signed.split('.')).toHaveLength(3)
})
it('encodes scopes when signing', async () => {
await inst.grantScope('foo', 'bar')
const signed = await inst.sign('goodKey', { subject: 'foo' })
inst = new Token({ ...tokenOpts, token: signed })
await expect(inst.hasScope('foo', 'bar')).resolves.toBe(true)
})
it('supports options without scopes', async () => {
inst.setOption('foo', 'bar', 'baz')
const signed = await inst.sign('goodKey', { subject: 'foo' })
inst = new Token({ token: signed })
expect(inst.getOption('foo', 'bar')).toEqual('baz')
})
it('assigns the given expiration', async () => {
const signed = await inst.sign('goodKey', {
subject: 'foo',
expiresInSecs: 60
})
inst = new Token({ token: signed })
const expiredAt = Math.floor(Date.now() / 1000) + 61
expect(inst.claims.exp).toBeLessThan(expiredAt)
})
it('allows the subject to exist in a claim only', async () => {
inst.claims.sub = 'foo@bar.baz'
await expect(inst.sign('goodKey')).resolves.toBeDefined()
})
it('throws when there is no subject', async () => {
const throws = () => inst.sign('goodKey')
await expect(throws).rejects.toThrowError(/subject is required/)
})
it('throws when the key ID is not in the key map', async () => {
await expect(inst.sign('noKey', { subject: 'foo' })).rejects.toThrow(
KeyNotFoundError
)
})
it('throws when the key has no priv property', async () => {
await expect(inst.sign('emptyKey', { subject: 'foo' })).rejects.toThrow(
KeyNotFoundError
)
})
it('throws when the key has no algo property', async () => {
await expect(inst.sign('privOnly', { subject: 'foo' })).rejects.toThrow(
KeyNotFoundError
)
})
it('rejects if the key is corrupt', async () => {
await expect(
inst.sign('corruptKey', { subject: 'foo' })
).rejects.toThrowError()
})
it('calls getPrivateKey when the private key is not found', async () => {
inst = new Token({
getPrivateKey: (kid) => {
const { privateKey, algorithm } = tokenOpts.keys[kid]
return { privateKey, algorithm }
}
})
const signed = await inst.sign('privAlgo', { subject: 'foo' })
expect(signed).toBeDefined()
})
it('calls getPrivateKey when only a pub key is present', async () => {
inst = new Token({
getPrivateKey: (kid) => {
const { privateKey, algorithm } = tokenOpts.keys[kid]
return { privateKey, algorithm }
},
keys: { privAlgo: { publicKey: 'PEM', algorithm: 'RS512' } }
})
const signed = await inst.sign('privAlgo', { subject: 'foo' })
expect(signed).toBeDefined()
})
it('supports promises from getPrivateKey', async () => {
inst = new Token({
getPrivateKey: async (kid) => {
const { privateKey, algorithm } = tokenOpts.keys[kid]
return { privateKey, algorithm }
}
})
const signed = await inst.sign('privAlgo', { subject: 'foo' })
expect(signed).toBeDefined()
})
it('caches the results of getPrivateKey', async () => {
const getPrivateKey = jest.fn(async (kid) => {
const { privateKey, algorithm } = tokenOpts.keys[kid]
return { privateKey, algorithm }
})
inst = new Token({ getPrivateKey })
await inst.sign('privAlgo', { subject: 'foo' })
expect(getPrivateKey).toBeCalledTimes(1)
await inst.sign('privAlgo', { subject: 'foo' })
expect(getPrivateKey).toBeCalledTimes(1)
})
it('supports signing when an exp claim is already set', async () => {
const now = Math.floor(Date.now() / 1000)
inst.claims.exp = now
const signed = await inst.sign('privAlgo', { subject: 'foo' })
inst = new Token({ token: signed })
expect(inst.claims.exp).toEqual(now + 3600)
})
it('includes audience and issuer in signing opts', async () => {
const token = await inst.sign('privAlgo', {
subject: 'foo',
audience: 'bar',
issuer: 'baz'
})
inst = new Token({ token })
expect(inst.claims).toEqual(
expect.objectContaining({
sub: 'foo',
aud: 'bar',
iss: 'baz'
})
)
})
it('includes audience and issuer in token opts', async () => {
inst = new Token({
...tokenOpts,
audience: 'bar',
issuer: 'baz'
})
const token = await inst.sign('privAlgo', { subject: 'foo' })
inst = new Token({ token })
expect(inst.claims).toEqual(
expect.objectContaining({
sub: 'foo',
aud: 'bar',
iss: 'baz'
})
)
})
})
describe('verify', () => {
it('verifies a token using KID in the same instance', async () => {
await inst.sign('goodKey', { subject: 'foo' })
await expect(inst.verify()).resolves.toBeTruthy()
})
it('verifies a token using a provided key', async () => {
await inst.sign('goodKey', { subject: 'foo' })
await expect(inst.verify({ key: pub })).resolves.toBeTruthy()
})
it('verifies a token passed to the constructor', async () => {
inst = new Token(Object.assign({ token: testTokenStr }, tokenOpts))
await expect(
inst.verify({ ignoreExpiration: true })
).resolves.toBeTruthy()
})
it('fails to verify an unsigned token', async () => {
await expect(inst.verify()).rejects.toThrow(/No token string to verify/)
})
it('fails validation using maxAgeSecs', async () => {
inst = new Token(Object.assign({ token: testTokenStr }, tokenOpts))
await expect(
inst.verify({ ignoreExpiration: true, maxAgeSecs: 10 })
).rejects.toThrowError(TokenExpiredError)
})
it('calls function to get pub key via Promise', async () => {
const getPublicKey = jest.fn().mockResolvedValue(pub)
const signed = await inst.sign('goodKey', { subject: 'foo' })
inst = new Token({ token: signed, getPublicKey })
await expect(inst.verify()).resolves.toBeTruthy()
expect(getPublicKey).toBeCalledTimes(1)
expect(getPublicKey).toBeCalledWith('goodKey')
})
it('calls function to get pub key synchronously', async () => {
const getPublicKey = jest.fn().mockReturnValue(pub)
const signed = await inst.sign('goodKey', { subject: 'foo' })
inst = new Token({ token: signed, getPublicKey })
await expect(inst.verify()).resolves.toBeTruthy()
expect(getPublicKey).toBeCalledTimes(1)
expect(getPublicKey).toBeCalledWith('goodKey')
})
it('gets public key when private key already exists', async () => {
const getPublicKey = jest.fn().mockResolvedValue(pub)
const token = await inst.sign('privAlgo', { subject: 'foo' })
inst = new Token({ ...tokenOpts, token, getPublicKey })
await expect(inst.verify()).resolves.toBeTruthy()
})
it('throws KeyNotFoundError when no pub key exists', async () => {
const token = await inst.sign('goodKey', { subject: 'foo' })
inst = new Token({ token })
await expect(inst.verify()).rejects.toThrow(KeyNotFoundError)
})
it('verifies a token signed with an HMAC key', async () => {
const token = await inst.sign('hmac', { subject: 'foo' })
inst = new Token({ ...tokenOpts, token })
await expect(inst.verify()).resolves.toBeTruthy()
})
it('verifies a matching nonce claim', async () => {
inst.claims.nonce = 'foo'
const token = await inst.sign('hmac', { subject: 'foo' })
inst = new Token({ ...tokenOpts, token })
await expect(inst.verify({ nonce: 'foo' })).resolves.toBeTruthy()
})
it('fails a non-matching nonce claim', async () => {
inst.claims.nonce = 'foo'
const token = await inst.sign('hmac', { subject: 'foo' })
inst = new Token({ ...tokenOpts, token })
await expect(inst.verify({ nonce: 'bar' })).rejects.toThrow(
JsonWebTokenError
)
})
it('verifies a restricted set of algorithms', async () => {
const token = await inst.sign('hmac', { subject: 'foo' })
inst = new Token({ ...tokenOpts, token })
await expect(inst.verify({ algorithms: ['HS256'] })).resolves.toBeTruthy()
})
it('fails a restricted set of algorithms', async () => {
const token = await inst.sign('hmac', { subject: 'foo' })
inst = new Token({ ...tokenOpts, token })
await expect(inst.verify({ algorithms: ['RS256'] })).rejects.toThrow(
JsonWebTokenError
)
})
})
})