diff --git a/api/jwt/decode.mjs b/api/jwt/decode.mjs index 716f49a..0eee4d5 100644 --- a/api/jwt/decode.mjs +++ b/api/jwt/decode.mjs @@ -44,8 +44,14 @@ const defaultOptions = { throw new Error('JWT does not contain 3 dots') } - let header = JSON.parse(base64UrlSafe.decode(parts[0]).toString('utf8')) - let body = JSON.parse(base64UrlSafe.decode(parts[1]).toString('utf8')) + let header + let body + try { + header = JSON.parse(base64UrlSafe.decode(parts[0]).toString('utf8')) + body = JSON.parse(base64UrlSafe.decode(parts[1]).toString('utf8')) + } catch (err) { + throw new Error('Token was invalid') + } if (typeof options.fixup === 'function') { options.fixup(header, body) } @@ -85,7 +91,7 @@ const defaultOptions = { if (!pubkeyOrSharedKey) { throw new Error( - `Unknown pubkey id '${header.kid}' for this issuer` + `Unknown pubkey id '${header.kid || 'default'}' on this issuer` ) } else if ((typeof(pubkeyOrSharedKey) !== 'object' || Array.isArray(pubkeyOrSharedKey)) && typeof(pubkeyOrSharedKey) !== 'string') { throw new Error( @@ -147,6 +153,8 @@ function validateIssuedAt(body, unixNow, options) { } function validateAudience(body, audiences, options) { + if (!body.aud && audiences.length === 0) return + let auds = Array.isArray(body.aud) ? body.aud : [body.aud] if (!auds.some(aud => audiences.includes(aud))) { throw new Error(`Unknown audience '${auds.join(',')}'`) diff --git a/api/jwt/encode.mjs b/api/jwt/encode.mjs index e45063d..d3c2fe1 100644 --- a/api/jwt/encode.mjs +++ b/api/jwt/encode.mjs @@ -1,7 +1,9 @@ import crypto from 'crypto' import * as base64UrlSafe from './base64urlsafe.mjs' +import defaults from '../defaults.mjs' -export default function encode(header, body, privateKeyPassword = null) { +export default function encode(header, orgBody, privateKeyPassword = null, disableAutofill = false) { + let body = defaults(orgBody) if ( typeof header !== 'object' || Array.isArray(header) || @@ -28,6 +30,13 @@ export default function encode(header, body, privateKeyPassword = null) { ) } + if (!body.iat && !disableAutofill) { + body.iat = Math.floor(Date.now() / 1000) + } + if (!body.exp && !disableAutofill) { + body.exp = body.iat + 300 + } + // Base64 encode header and body let headerBase64 = base64UrlSafe.encode(Buffer.from(JSON.stringify(header))) let bodyBase64 = base64UrlSafe.encode(Buffer.from(JSON.stringify(body))) diff --git a/api/media/security.mjs b/api/media/security.mjs index 8344557..d34cd0c 100644 --- a/api/media/security.mjs +++ b/api/media/security.mjs @@ -1,21 +1,13 @@ // import * as jwt from '../jwt.mjs' +import decode from '../jwt/decode.mjs' import config from '../config.mjs' -export async function verifyToken(ctx) { +export function verifyToken(ctx) { if (!ctx.query.token) { throw new Error('Token is missing in query') } - /*let decoded = jwt.decode(ctx.query.token) + let decoded = decode(ctx.query.token, config.get('sites'), []) - if (!decoded || !decoded.site) { - throw new Error('Token is invalid') - } - - let output = await jwt.verify( - ctx.query.token, - config.get(`sites:${decoded.site}`) - )*/ - - return output.site + return decoded.iss } diff --git a/test/jwt/decode.test.mjs b/test/jwt/decode.test.mjs index d6225fe..2dfc095 100644 --- a/test/jwt/decode.test.mjs +++ b/test/jwt/decode.test.mjs @@ -57,7 +57,7 @@ t.describe('jwtUtils', function() { function() { decode(testJwt.substr(10), pubKeys, audiences) }, - /Unexpected token \$ in JSON at position 0/ + /Token was invalid/ ) }) t.test('wrong alg', function() { diff --git a/test/jwt/jwt.test.mjs b/test/jwt/jwt.test.mjs index 5634951..bb72dd7 100644 --- a/test/jwt/jwt.test.mjs +++ b/test/jwt/jwt.test.mjs @@ -61,6 +61,22 @@ t.describe('encode/decode', function() { ]) assert.deepStrictEqual(jwtBody, decodedJwtBody) }) + t.test('success without iat or iss', function() { + let customJwtBody = defaults(jwtBody) + delete customJwtBody.iat + delete customJwtBody.exp + let jwt = encode(jwtHeader, customJwtBody, 'sharedkey') + let decodedJwtBody = decode(jwt, pubKeys, [ + 'https://host/oauth/token' + ]) + assert.ok(decodedJwtBody.iat) + assert.ok(decodedJwtBody.iat >= unixNow) + assert.ok(decodedJwtBody.iat < unixNow + 2) + assert.ok(decodedJwtBody.exp) + assert.ok(decodedJwtBody.exp >= unixNow) + assert.ok(decodedJwtBody.exp > unixNow + 200) + assert.ok(decodedJwtBody.exp < unixNow + 400) + }) t.test('success with object key', function() { let customJwtBody = defaults(jwtBody) customJwtBody.kid = '5' @@ -83,6 +99,13 @@ t.describe('encode/decode', function() { ]) assert.deepStrictEqual(customJwtBody, decodedJwtBody) }) + t.test('success with no aud and empty audience list', function() { + let customJwtBody = defaults(jwtBody) + delete customJwtBody.aud + let jwt = encode(jwtHeader, customJwtBody, 'sharedkey') + let decodedJwtBody = decode(jwt, pubKeys, []) + assert.deepStrictEqual(customJwtBody, decodedJwtBody) + }) t.test('success with expired token', function() { let customJwtBody = defaults(jwtBody) customJwtBody.iss = 'test@custom.com' @@ -168,10 +191,18 @@ t.describe('encode/decode', function() { /Token has expired/ ) }) + t.test('invalid', function() { + assert.throws( + function() { + decode('asdfasdgassdga.asdfasdg.sadfsadfas', pubKeys, ['https://host/oauth/token']) + }, + /Token was invalid/ + ) + }) t.test('missing exp', function() { let customJwtBody = defaults(jwtBody) delete customJwtBody.exp - let jwt = encode(jwtHeader, customJwtBody, 'sharedkey') + let jwt = encode(jwtHeader, customJwtBody, 'sharedkey', true) assert.throws( function() { decode(jwt, pubKeys, ['https://host/oauth/token']) @@ -241,7 +272,7 @@ t.describe('encode/decode', function() { function() { decode(jwt, pubKeys, ['https://host/oauth/token']) }, - /Unknown pubkey id '3' for this issuer/ + /Unknown pubkey id '3'/ ) }) t.test('invalid signature', function() { diff --git a/test/media/api.test.js b/test/media/api.test.js index 2521244..5f72833 100644 --- a/test/media/api.test.js +++ b/test/media/api.test.js @@ -1,38 +1,38 @@ +import { Eltro as t, assert} from 'eltro' import fs from 'fs' -import assert from 'assert' -import sinon from 'sinon' -import 'assert-extended' -import appRoot from 'app-root-path' +import { fileURLToPath } from 'url' +import path from 'path' -import createClient from '../helper.client' +import createClient from '../helper.client.mjs' +import config from '../../api/config.mjs' +import jwt from '../../api/jwt.mjs' -describe('Media (API)', () => { - let config = require('../../config') - let jwt = require('../../api/jwt') +let __dirname = path.dirname(fileURLToPath(import.meta.url)) + +console.log(__dirname) + +t.describe('Media (API)', () => { let testFile - let client + let client = createClient() - before(() => { + t.before(() => { config.set('sites', { development: 'hello-world' }) }) - after(done => { + t.after(done => { if (testFile) { - return fs.unlink(appRoot.resolve(`/public${testFile}`), done) + // path.resolve(path.join(__dirname, 'fixtures', file)) + return fs.unlink( appRoot.resolve(`/public${testFile}`), done) } done() }) - beforeEach(() => { - client = createClient() - }) - - describe('POST /media', function temp() { + t.describe('POST /media', function temp() { this.timeout(10000) - it('should require authentication', async () => { + t.test('should require authentication', async () => { let err = await assert.isRejected( client.sendFileAsync('/media', appRoot.resolve('/test/media/test.png'))) @@ -41,7 +41,7 @@ describe('Media (API)', () => { assert.match(err.message, /[Tt]oken/) }) - it('should upload file and create file', async () => { + t.test('should upload file and create file', async () => { let token = jwt.sign({ site: 'development' }, 'hello-world') let data = await assert.isFulfilled( diff --git a/test/media/security.test.js b/test/media/security.test.js deleted file mode 100644 index 6b1af4a..0000000 --- a/test/media/security.test.js +++ /dev/null @@ -1,101 +0,0 @@ -import assert from 'assert-extended' -import sinon from 'sinon' - -import { createContext } from '../helper.server' - -describe('Media (Security)', () => { - const security = require('../../api/media/security') - const jwt = require('../../api/jwt') - const config = require('../../config') - - let sandbox - let ctx - - beforeEach(() => { - sandbox = sinon.sandbox.create() - config.set('sites', { - test: 'secret', - }) - ctx = createContext({ - query: { - token: 'asdf', - }, - }) - }) - - afterEach(() => { - sandbox.restore() - }) - - describe('#verifyToken()', () => { - let stubVerify - let stubDecode - - beforeEach(() => { - stubVerify = sandbox.stub(jwt, 'verify') - stubDecode = sandbox.stub(jwt, 'decode').returns({ site: 1 }) - }) - - it('should fail if query token is missing', async () => { - delete ctx.query.token - - let err = await assert.isRejected(security.verifyToken(ctx)) - - assert.ok(err) - assert.match(err.message, /[tT]oken/) - assert.match(err.message, /[Mm]issing/) - }) - - it('should fail if token is invalid', async () => { - const assertToken = 'asdfasdfas' - ctx.query.token = assertToken - stubDecode.returns(null) - - let err = await assert.isRejected(security.verifyToken(ctx)) - - assert.ok(err) - assert.ok(stubDecode.called) - assert.strictEqual(stubDecode.firstCall.args[0], assertToken) - assert.match(err.message, /[tT]oken/) - assert.match(err.message, /[Ii]nvalid/) - }) - - it('should fail if token does not have site', async () => { - stubDecode.returns({ s: 1 }) - - let err = await assert.isRejected(security.verifyToken(ctx)) - - assert.ok(err) - assert.ok(stubDecode.called) - assert.match(err.message, /[tT]oken/) - assert.match(err.message, /[Ii]nvalid/) - }) - - it('should fail if secret does not match one in config', async () => { - const assertError = new Error('lethal') - const assertToken = 'ewgowae' - ctx.query.token = assertToken - config.set('sites', { herp: 'derp' }) - - - stubDecode.returns({ site: 'herp' }) - stubVerify.rejects(assertError) - - let err = await assert.isRejected(security.verifyToken(ctx)) - - assert.ok(stubVerify.called) - assert.strictEqual(err, assertError) - assert.strictEqual(stubVerify.firstCall.args[0], assertToken) - assert.strictEqual(stubVerify.firstCall.args[1], 'derp') - }) - - it('should otherwise return the site name', async () => { - const assertSiteName = 'asdfasdfasdf' - stubVerify.resolves({ site: assertSiteName }) - - let site = await security.verifyToken(ctx) - - assert.strictEqual(site, assertSiteName) - }) - }) -}) diff --git a/test/media/security.test.mjs b/test/media/security.test.mjs new file mode 100644 index 0000000..c2d782f --- /dev/null +++ b/test/media/security.test.mjs @@ -0,0 +1,67 @@ +import { Eltro as t, assert} from 'eltro' + +import { createContext } from '../helper.server.mjs' +import { verifyToken } from '../../api/media/security.mjs' +import encode from '../../api/jwt/encode.mjs' +import config from '../../api/config.mjs' + +t.describe('#verifyToken()', function() { + t.before(function() { + config.set('sites', { + justatest: { + 'default@HS512': 'mysharedkey' + }, + }) + }) + + t.test('should fail if query token is missing', function() { + let ctx = createContext({ }) + delete ctx.query.token + + assert.throws(function() { verifyToken(ctx) }, /[Mm]issing/) + }) + + t.test('should fail if token is invalid', function() { + let ctx = createContext({ }) + ctx.query.token = 'asdfasdgassdga' + + assert.throws(function() { verifyToken(ctx) }) + + ctx.query.token = 'asdfasdgassdga.asdfasdg.sadfsadfas' + + assert.throws(function() { verifyToken(ctx) }, /invalid/) + + ctx.query.token = encode( + { typ: 'JWT', alg: 'HS256' }, + { iss: 'justatest' }, + 'mysharedkey' + ) + assert.throws(function() { verifyToken(ctx) }, /pubkey/) + + ctx.query.token = encode( + { typ: 'JWT', alg: 'HS512' }, + { iss: 'notexist' }, + 'mysharedkey' + ) + assert.throws(function() { verifyToken(ctx) }, /notexist/) + + ctx.query.token = encode( + { typ: 'JWT', alg: 'HS512' }, + { iss: 'justatest' }, + 'mysharedkey2' + ) + assert.throws(function() { verifyToken(ctx) }, /HS512/) + assert.throws(function() { verifyToken(ctx) }, /[vV]erification/) + }) + + t.test('should otherwise return the issuer', function() { + let ctx = createContext({ }) + ctx.query.token = encode( + { typ: 'JWT', alg: 'HS512' }, + { iss: 'justatest' }, + 'mysharedkey' + ) + let site = verifyToken(ctx) + assert.strictEqual(site, 'justatest') + }) +})