predix-fast-token
Version:
Node module to verify UAA tokens used when protecting REST endpoints
386 lines (352 loc) • 20.4 kB
JavaScript
;
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');
chai.use(chaiAsPromised);
const expect = chai.expect;
const rp = require('request-promise');
const sinon = require('sinon');
const token_util = require('../index');
const key1 = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0m59l2u9iDnMbrXHfqkO\nrn2dVQ3vfBJqcDuFUK03d+1PZGbVlNCqnkpIJ8syFppW8ljnWweP7+LiWpRoz0I7\nfYb3d8TjhV86Y997Fl4DBrxgM6KTJOuE/uxnoDhZQ14LgOU2ckXjOzOdTsnGMKQB\nLCl0vpcXBtFLMaSbpv1ozi8h7DJyVZ6EnFQZUWGdgTMhDrmqevfx95U/16c5WBDO\nkqwIn7Glry9n9Suxygbf8g5AzpWcusZgDLIIZ7JTUldBb8qU2a0Dl4mvLZOn4wPo\njfj9Cw2QICsc5+Pwf21fP+hzf+1WSRHbnYv8uanRO0gZ8ekGaghM/2H6gqJbo2nI\nJwIDAQAB\n-----END PUBLIC KEY-----\n";
const key2 = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5VXMZBf2fUqNViwhkaKC\ntpnKX4MgKAcFA8KGiFYgChss8v/yB41wA8f+UfJmCOMIswRELKjHOp4tm9XtkCqy\nO/09RHqkrxG33za5tUhXSLaYX9MyMJcvbAXJ8cE9uu5Hv6Q4Gs65q/brwchh87Yb\nlCCvqGQ7QggEjqt2+bWGgjHDw9pKBXXRkA8t3fsH+sh2YgGCoRHH5Dd5QKpVkIGW\nnXlNIjRTd4g7rjE4Y3F1TaAhHpCoMOdviR++RIs3PdCi8ZUoS7V+mCWwOr61D7At\nxBjdnDDu/PZgLxlt1JEXt07V0xTzjztJ4r8qz5PkBZJeuZpHmiZoDNEquOhMQPhB\nIQIDAQAB\n-----END PUBLIC KEY-----\n"
const token1_valid = 'eyJhbGciOiJSUzI1NiIsImtpZCI6ImxlZ2FjeS10b2tlbi1rZXkiLCJ0eXAiOiJKV1QifQ.eyJqdGkiOiIwOTkxNTYzZjVjYTI0YjM5YjAxYzAzZjVlMTBmMTY0YiIsInN1YiI6IjMxZmJjZGU1LTc2NWUtNDA4ZS1hMjhjLTBhMjM0OTQ1YzkxYSIsInNjb3BlIjpbIm9wZW5pZCJdLCJjbGllbnRfaWQiOiJ0ZXN0IiwiY2lkIjoidGVzdCIsImF6cCI6InRlc3QiLCJncmFudF90eXBlIjoiYXV0aG9yaXphdGlvbl9jb2RlIiwidXNlcl9pZCI6IjMxZmJjZGU1LTc2NWUtNDA4ZS1hMjhjLTBhMjM0OTQ1YzkxYSIsIm9yaWdpbiI6InVhYSIsInVzZXJfbmFtZSI6InRlc3RlciIsImVtYWlsIjoidGVzdGVyQGRlbW8ubG9jYWwiLCJhdXRoX3RpbWUiOjE0NjM1ODE1NjQsInJldl9zaWciOiI4YTBkMzVjZSIsImlhdCI6MTQ2MzU4MTYxNSwiZXhwIjozNjExMDY1MjYyLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvdWFhL29hdXRoL3Rva2VuIiwiemlkIjoidWFhIiwiYXVkIjpbInRlc3QiLCJvcGVuaWQiXX0.bG36YmWafz1B7ZH-kMX4Wh_xDRpwGUNYGn2Cizxr3ywmWE7gsupDrIpzmGnlG389IGzMGfqEb_nwtHT8mqhLpxN-IwT1SIz9qWDH4kt07qsJGWnzAIDH_fF6np_iMghz6JQJsLYG5rIKoR7ibNJl4xK6PhoIk4F7Rw2GuLcKuq9ILQRRAJTfuzZEBjVIwqTbDulXgOveCbagjPF455i_QxsxMzpq001nlCN6OfjCbNpPnLjpFUp4eZ3K-gGQfdLTxMEgjnfl7B-U45vtOPBJ0sXIXvfOUXWneSt6BkPka3GCcz3GdqmYDbNvsZD5IRyCDjQ0sZv7IHZHQf-vgLReLg';
const token1_expired = 'eyJhbGciOiJSUzI1NiIsImtpZCI6ImxlZ2FjeS10b2tlbi1rZXkiLCJ0eXAiOiJKV1QifQ.eyJqdGkiOiIwMzE1ZjFmYTFhOWE0MDFjOTc0Y2U0ZGUyMjA1MDQ5MiIsInN1YiI6IjMxZmJjZGU1LTc2NWUtNDA4ZS1hMjhjLTBhMjM0OTQ1YzkxYSIsInNjb3BlIjpbIm9wZW5pZCJdLCJjbGllbnRfaWQiOiJ0ZXN0IiwiY2lkIjoidGVzdCIsImF6cCI6InRlc3QiLCJncmFudF90eXBlIjoiYXV0aG9yaXphdGlvbl9jb2RlIiwidXNlcl9pZCI6IjMxZmJjZGU1LTc2NWUtNDA4ZS1hMjhjLTBhMjM0OTQ1YzkxYSIsIm9yaWdpbiI6InVhYSIsInVzZXJfbmFtZSI6InRlc3RlciIsImVtYWlsIjoidGVzdGVyQGRlbW8ubG9jYWwiLCJhdXRoX3RpbWUiOjE0NjM1ODE1NjQsInJldl9zaWciOiI4YTBkMzVjZSIsImlhdCI6MTQ2MzU4MTU3NiwiZXhwIjoxNDYzNTgxNTc3LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvdWFhL29hdXRoL3Rva2VuIiwiemlkIjoidWFhIiwiYXVkIjpbInRlc3QiLCJvcGVuaWQiXX0.pZIIZlKMmNeMz-Rh_np1Rfo3Cj-cFW9M6i5c9U6ZUHy1I2Xz7r6Esan-ED16yxpayYfCTE40s0ukSAFkqpxDO3gtmFjvZqIv-APclZXklIJthR8l8KgBkwZ2I5eGIi__qKl1ydkTmPke9qXyDqIQQnRnqoSzA5aI5rza9XDbT7rJwJCbhvGYpP2GQ2roapSweTkagTmrcgyhKWxf8NA36yQ4eFh_JZ4Qj8zHRWFU3PvdR812a7mvm8o6ECsIPqKwg10kXh61sjASoFsO6bxlw6dGgP8j5PrHfcWO74MYuGa1S1IaaeafHm2i29zJ2iBdNq3PCQuPrvxQdiFW_L7wdg';
const token1_tampered = 'eyJhbGciOiJSUzI1NiIsImtpZCI6ImxlZ2FjeS10b2tlbi1rZXkiLCJ0eXAiOiJKV1QifQ.eyJqdGkiOiIwOTkxNTYzZjVjYTI0YjM5YjAxYzAzZjVlMTBmMTY0YiIsInN1YiI6IjMxZmJjZGU1LTc2NWUtNDA4ZS1hMjhjLTBhMjM0OTQ1YzkxYSIsInNjb3BlIjpbIm9wZW5pZCIsImFkbWluIl0sImNsaWVudF9pZCI6InRlc3QiLCJjaWQiOiJ0ZXN0IiwiYXpwIjoidGVzdCIsImdyYW50X3R5cGUiOiJhdXRob3JpemF0aW9uX2NvZGUiLCJ1c2VyX2lkIjoiMzFmYmNkZTUtNzY1ZS00MDhlLWEyOGMtMGEyMzQ5NDVjOTFhIiwib3JpZ2luIjoidWFhIiwidXNlcl9uYW1lIjoidGVzdGVyIiwiZW1haWwiOiJ0ZXN0ZXJAZGVtby5sb2NhbCIsImF1dGhfdGltZSI6MTQ2MzU4MTU2NCwicmV2X3NpZyI6IjhhMGQzNWNlIiwiaWF0IjoxNDYzNTgxNjE1LCJleHAiOjM2MTEwNjUyNjIsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC91YWEvb2F1dGgvdG9rZW4iLCJ6aWQiOiJ1YWEiLCJhdWQiOlsidGVzdCIsIm9wZW5pZCJdfQ.bG36YmWafz1B7ZH-kMX4Wh_xDRpwGUNYGn2Cizxr3ywmWE7gsupDrIpzmGnlG389IGzMGfqEb_nwtHT8mqhLpxN-IwT1SIz9qWDH4kt07qsJGWnzAIDH_fF6np_iMghz6JQJsLYG5rIKoR7ibNJl4xK6PhoIk4F7Rw2GuLcKuq9ILQRRAJTfuzZEBjVIwqTbDulXgOveCbagjPF455i_QxsxMzpq001nlCN6OfjCbNpPnLjpFUp4eZ3K-gGQfdLTxMEgjnfl7B-U45vtOPBJ0sXIXvfOUXWneSt6BkPka3GCcz3GdqmYDbNvsZD5IRyCDjQ0sZv7IHZHQf-vgLReLg';
const token_malformed = 'eyJhbGciOiJSUzI1NiIsImtpZCI6ImxlZ2FjeS10b2tlbi1rZXkiLCJ0eXAiOiJKV1QifQ.eyJqdGkiOiIwOTkxNTYzZjVjYTI0YjM5YjAxYzAzZjVlMTBmMTY0YiIsInN1YiI6IjMxZmJjZGU1LTc2NWUtNDA4ZS1hMjhjLTBhMjM0OTQ1YzkxYSIsInNjb3BlIjpbIm9wZW5pZCJdLCJjbGllbnRfaWQiOjJ0ZXN0IiwiY2lkIjoidGVzdCIsImF6cCI6InRlc3QiLCJncmFudF90eXBlIjoiYXV0aG9yaXphdGlvbl9jb2RlIiwidXNlcl9pZCI6IjMxZmJjZGU1LTc2NWUtNDA4ZS1hMjhjLTBhMjM0OTQ1YzkxYSIsIm9yaWdpbiI6InVhYSIsInVzZXJfbmFtZSI6InRlc3RlciIsImVtYWlsIjoidGVzdGVyQGRlbW8ubG9jYWwiLCJhdXRoX3RpbWUiOjE0NjM1ODE1NjQsInJldl9zaWciOiI4YTBkMzVjZSIsImlhdCI6MTQ2MzU4MTYxNSwiZXhwIjozNjExMDY1MjYyLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvdWFhL29hdXRoL3Rva2VuIiwiemlkIjoidWFhIiwiYXVkIjpbInRlc3QiLCJvcGVuaWQiXX0.EmHihs0D2OXg3bilcq0rH2Rd31BunqKDY9etUOZva1jyXhUe7Im79KmOqwFpMujTe4ONyN2rm70m8vhsJjfxBiS-n6-84ZJRKrN4FIIpil8gqQXNRUQSUn513lj0_suZAl5_4jxwrDyk1L00q3hdfHO2IP9hxcKiXp_jtRZlHumUpR0pG411gNMnZYxmrQio08prPGqcUA2LOLFHBtg6QVYF_Ho0jOBl4AAHqVxpMfPHHrOuX5aYhTXbp__3Gefsv44TmfNvzK_LnVtC5LWoCJvuiUhz45agkeMIR5NDsNc_cA7G148-TjwCYIfJFEUut6j2y4qNJSrum-J-1T7IYg';
const token_not_jwt = 'This is not a JWT';
const token_opaque = 'dfbe8dbc2d814438897c6cbb6e2363f5';
// Note: this is not a valid JWT - hand modified for test results
const token_opaque_decoded = {
user_id: '0bc9fe45-6c9e-4ae8-bde4-bde5a7d12345',
user_name: 'testuser',
email: 'test_user@predix.io',
client_id: 'uaaClient',
exp: 3497020914,
scope: ['openid'],
jti: 'dfbe8dbc2d814438897c6cbb6e2363f5',
aud: ['openid', 'uaaClient'],
sub: '0bc9fe45-6c9e-4ae8-bde4-cde3a7d12932',
iss: 'https://uaa.example.predix.io/oauth/token',
iat: 1477334362,
cid: 'uaaClient',
grant_type: 'authorization_code',
azp: 'uaaClient',
auth_time: 1477334357,
zid: 'a8a2ffc4-b04e-4ec1-bfed-bde5a7d12345',
rev_sig: '91a62430',
nonce: 'cb296893856f20c0b1bf56b0a9ca8914',
origin: 'example-uaa',
revocable: true
};
const badTokenResponse = token => ({ error: { error: 'invalid_token', error_description: `The token expired, was revoked, or the token ID is incorrect: ${token}` }});
const badCredentialsResponse = () => ({ error: { error: 'unauthorized', error_description: 'Bad credentials' }});
const validClient = { issuer: 'https://uaa.example.predix.io/oauth/token', clientId: 'uaaClient', clientSecret: 'secret' };
const invalidClient = { issuer: 'https://uaa.example.predix.io/oauth/token', clientId: 'uaaClient', clientSecret: 'nogood' };
const missingClient = { issuer: 'https://no.uaa.here.com/oauth/token', clientId: 'uaaClient', clientSecret: 'nogood' };
const trusted_issuers = ['http://localhost:8080/uaa/oauth/token', 'https://uaa.example.com/oauth/token'];
const trusted_issuers2 = ['https://uaa.evil.gov/oauth/token'];
let reqStub;
let postStub;
let cacheSetSpy;
let cacheGetSpy;
// ====================================================
// MOCKS
beforeEach((done) => {
// Mock out the get call for fetching the key (happy path)
reqStub = sinon.stub(rp, 'get');
reqStub.returns(Promise.resolve(JSON.stringify({ value: key1 })));
// Mock out the post call for check_token (happy path)
postStub = sinon.stub(rp, 'post');
postStub.returns(Promise.resolve(token_opaque_decoded));
// Clean out any cached keys
token_util.clearCache();
// Spy on cache
cacheSetSpy = sinon.spy(token_util._tokenCache, 'set');
cacheGetSpy = sinon.spy(token_util._tokenCache, 'get');
done();
});
afterEach((done) => {
rp.get.restore();
rp.post.restore();
cacheSetSpy.restore();
cacheGetSpy.restore();
done();
});
// ====================================================
// TESTS
describe('#verify', () => {
it('verify a token', (done) => {
// Use a token that expires 68 years in future
// It is valid and signed by the correct key
token_util.verify(token1_valid, trusted_issuers).then((decoded) => {
try {
expect(reqStub.calledOnce, '/token_key called once').to.be.true;
expect(reqStub.calledWith({uri: 'http://localhost:8080/uaa/token_key'}), '/token_key at right URI').to.be.true;
expect(decoded).to.exist;
expect(decoded.user_name).to.equal('tester');
done();
} catch (e) {
return done(e);
}
});
});
it('verify a token with tenant uuid', (done) => {
// Use a token that expires 68 years in future
// It is valid and signed by the correct key
token_util.verify(token1_valid, trusted_issuers, 'xxxxxxx').then((decoded) => {
try {
expect(reqStub.calledOnce, '/token_key called once').to.be.true;
expect(reqStub.calledWith({uri: 'http://localhost:8080/uaa/token_key', headers: {tenant: 'xxxxxxx'}}), '/token_key at right URI').to.be.true;
expect(decoded).to.exist;
expect(decoded.user_name).to.equal('tester');
done();
} catch (e) {
return done(e);
}
});
});
it('cache the key', (done) => {
// Call verify twice. It should not call request on the second attempt
token_util.verify(token1_valid, trusted_issuers).then((decoded) => {
try {
expect(reqStub.calledOnce, '/token_key called only once').to.be.true;
expect(decoded).to.exist;
expect(decoded.user_name).to.equal('tester');
} catch (e) {
return done(e);
}
token_util.verify(token1_valid, trusted_issuers).then((decoded) => {
try {
expect(reqStub.calledOnce, '/token_key called only once').to.be.true;
expect(decoded).to.exist;
expect(decoded.user_name).to.equal('tester');
done();
} catch (e) {
return done(e);
}
});
});
});
it('cache the key with tenant uuid', (done) => {
// Call verify twice. It should not call request on the second attempt
token_util.verify(token1_valid, trusted_issuers, 'xxxxxxx').then((decoded) => {
try {
expect(reqStub.calledOnce, '/token_key called only once').to.be.true;
expect(decoded).to.exist;
expect(decoded.user_name).to.equal('tester');
} catch (e) {
return done(e);
}
token_util.verify(token1_valid, trusted_issuers, 'xxxxxxx').then((decoded) => {
try {
expect(reqStub.calledOnce, '/token_key called only once').to.be.true;
expect(decoded).to.exist;
expect(decoded.user_name).to.equal('tester');
done();
} catch (e) {
return done(e);
}
});
});
});
it('fail if unable to get the key', (done) => {
// Mock out the get call for fetching the key to give an error
rp.get.restore();
reqStub = sinon.stub(rp, 'get');
reqStub.returns(Promise.reject(JSON.stringify({ msg: 'nope' })));
token_util.verify(token1_valid, trusted_issuers).then((decoded) => {
done(new Error('Should fail if unable to get the key'));
}).catch(() => {
// We expect an error here
done();
});
});
it('fail if no response getting the key', (done) => {
// Mock out the get call for fetching the key to give an error
rp.get.restore();
reqStub = sinon.stub(rp, 'get');
reqStub.returns(Promise.reject());
token_util.verify(token1_valid, trusted_issuers).then((decoded) => {
done(new Error('Should fail no response getting the key'));
}).catch(() => {
// We expect an error here
done();
});
});
it('fail expired token', (done) => {
// Use a token that has already expired
// Although it is valid and signed by the correct key, verify should fail
token_util.verify(token1_expired, trusted_issuers).then((decoded) => {
done(new Error('Should fail if token is expired'));
}).catch((err) => {
// We expect an error here
try {
expect(err.name).to.equal('TokenExpiredError');
expect(err.message).to.equal('jwt expired');
done();
} catch (e) {
return done(e);
}
});
});
it('fail a tampered token', (done) => {
// Use a token that has been modified, verification should fail
token_util.verify(token1_tampered, trusted_issuers).then((decoded) => {
done(new Error('Should fail if token has been tampered with'));
}).catch((err) => {
// We expect an error here
try {
expect(err.name).to.equal('JsonWebTokenError');
expect(err.message).to.equal('invalid signature');
done();
} catch (e) {
return done(e);
}
});
});
it('fail a malformed token', (done) => {
// Use something that is not a token, verification should fail
token_util.verify(token_malformed, trusted_issuers).then((decoded) => {
done(new Error('Should fail if token is not a token'));
}).catch((err) => {
// We expect an error here
try {
expect(err.name).to.equal('Error');
expect(err.message).to.equal('Not a valid token');
done();
} catch (e) {
return done(e);
}
});
});
it('fail a non JWT token', (done) => {
// Use something that is not a token, verification should fail
token_util.verify(token_not_jwt, trusted_issuers).then((decoded) => {
done(new Error('Should fail if token is not a token'));
}).catch((err) => {
// We expect an error here
try {
expect(err.name).to.equal('Error');
expect(err.message).to.equal('Not a valid token');
done();
} catch (e) {
return done(e);
}
});
});
it('fail a null token', (done) => {
// Pass no token, verification should fail
token_util.verify(null, trusted_issuers).then((decoded) => {
done(new Error('Should fail if token is not supplied'));
}).catch((err) => {
// We expect an error here
try {
expect(err.name).to.equal('Error');
expect(err.message).to.equal('Not a valid token');
done();
} catch (e) {
return done(e);
}
});
});
it('fail signed with a different key', (done) => {
// Mock out the get call for fetching the key to give an error
rp.get.restore();
reqStub = sinon.stub(rp, 'get');
reqStub.returns(Promise.resolve(JSON.stringify({ value: key2 })));
// Using a key from another server, verification should fail
token_util.verify(token1_valid, trusted_issuers).then((decoded) => {
done(new Error('Should fail if token and key mismatch'));
}).catch((err) => {
// We expect an error here
try {
expect(err.name).to.equal('JsonWebTokenError');
expect(err.message).to.equal('invalid signature');
done();
} catch (e) {
return done(e);
}
});
});
it('fail if not a trusted issuer', (done) => {
// If the issuer is not trusted, verification should fail
token_util.verify(token1_valid, trusted_issuers2).then((decoded) => {
done(new Error('Should fail if issuer is not trusted'));
}).catch((err) => {
// We expect an error here
try {
expect(err.name).to.equal('Error');
expect(err.message).to.equal('Not a trusted issuer');
done();
} catch (e) {
return done(e);
}
});
});
});
describe('#remoteVerify', () => {
it('returns decoded on valid token', () => {
let verifyPromise = token_util.remoteVerify(token_opaque, validClient.issuer, validClient.clientId, validClient.clientSecret);
return expect(verifyPromise).to.eventually.be.fulfilled
.and.deep.equal(token_opaque_decoded);
});
it('returns error from UAA on invalid token', () => {
rp.post.restore();
postStub = sinon.stub(rp, 'post');
postStub.returns(Promise.reject(badTokenResponse(token1_expired)));
let verifyPromise = token_util.remoteVerify(token1_expired, validClient.issuer, validClient.clientId, validClient.clientSecret);
return expect(verifyPromise).to.eventually.be.rejected
.and.have.property('error', 'invalid_token');
});
it('returns error from UAA on bad client credentials', () => {
rp.post.restore();
postStub = sinon.stub(rp, 'post');
postStub.returns(Promise.reject(badCredentialsResponse()));
let verifyPromise = token_util.remoteVerify(token_opaque, invalidClient.issuer, invalidClient.clientId, invalidClient.clientSecret);
return expect(verifyPromise).to.eventually.be.rejected
.and.have.property('error', 'unauthorized');
});
it('caches until TTL expires', () => {
const ttl = 1000;
token_util.remoteVerify(token_opaque, validClient.issuer, validClient.clientId, validClient.clientSecret, { ttl: ttl })
.then((jwt) => {
expect(cacheSetSpy.calledOnce).to.be.true;
});
});
it('does not cache for 0 TTL', () => {
token_util.remoteVerify(token_opaque, validClient.issuer, validClient.clientId, validClient.clientSecret, { ttl: 0 })
.then((jwt) => {
return expect(cacheSetSpy.callCount).to.equal(0);
});
});
it('does not access cache if useCache disabled', () => {
const ttl = 1000;
return token_util.remoteVerify(token_opaque, validClient.issuer, validClient.clientId, validClient.clientSecret, { ttl: ttl, useCache: false })
.then((jwt) => {
return expect(cacheGetSpy.callCount).to.equal(0);
});
});
it('returns cached value if valid', () => {
const ttl = 1000;
return token_util.remoteVerify(token_opaque, validClient.issuer, validClient.clientId, validClient.clientSecret, { ttl: ttl, useCache: true })
.then((firstJwt) => {
expect(cacheSetSpy.calledOnce, 'cache set called only once').to.be.true;
expect(postStub.calledOnce, '/check_token called only once').to.be.true;
expect(cacheGetSpy.calledOnce, 'cache get called only once').to.be.true;
return token_util.remoteVerify(token_opaque, validClient.issuer, validClient.clientId, validClient.clientSecret, { ttl: ttl, useCache: true })
.then((secondJwt) => {
expect(cacheSetSpy.calledOnce, 'cache set called only once on second query').to.be.true;
expect(postStub.calledOnce, '/check_token called only once on second query').to.be.true;
expect(cacheGetSpy.calledTwice, 'cache get called twice on second query').to.be.true;
expect(secondJwt).to.deep.equal(firstJwt);
});
});
});
it('returns 404 on unknown url', () => {
rp.post.restore();
postStub = sinon.stub(rp, 'post');
postStub.returns(Promise.reject({ statusCode: 404 }));
let verifyPromise = token_util.remoteVerify(token1_expired, missingClient.issuer, missingClient.clientId, missingClient.clientSecret);
return expect(verifyPromise).to.eventually.be.rejected
.and.have.property('statusCode', 404);
});
});