Files
eSIM-Tools/tests/security/functions-auth.test.js

163 lines
4.8 KiB
JavaScript

describe('Functions authentication hardening', () => {
const originalEnv = process.env;
function loadModules(envOverrides = {}) {
jest.resetModules();
process.env = {
...originalEnv,
ACCESS_KEY: 'test-access-key',
ALLOWED_ORIGIN: 'https://esim.cosr.eu.org',
GIFFGAFF_CLIENT_ID: 'client-id',
GIFFGAFF_CLIENT_SECRET: 'abcdefghijklmnopqrstuvwxyzABCDEF',
GIFFGAFF_REDIRECT_URI: 'giffgaff://auth/callback/',
...envOverrides
};
return {
axios: require('axios'),
middleware: require('../../netlify/functions/_shared/middleware'),
tokenExchange: require('../../netlify/functions/giffgaff-token-exchange')
};
}
afterEach(() => {
process.env = originalEnv;
jest.restoreAllMocks();
});
it('rejects protected browserless calls without internal key', () => {
const { middleware } = loadModules();
expect(() => middleware.authenticate({
httpMethod: 'POST',
headers: {},
body: '{}',
queryStringParameters: {}
})).toThrow('Origin not allowed');
});
it('allows protected browserless internal calls with x-esim-key', () => {
const { middleware } = loadModules();
const result = middleware.authenticate({
httpMethod: 'POST',
headers: { 'x-esim-key': 'test-access-key' },
body: '{}',
queryStringParameters: {}
});
expect(result.authorized).toBe(true);
});
it('allows OPTIONS preflight for allowed origin', () => {
const { middleware } = loadModules();
const result = middleware.authenticate({
httpMethod: 'OPTIONS',
headers: { origin: 'https://esim.cosr.eu.org' },
body: '{}',
queryStringParameters: {}
});
expect(result.preflight).toBe(true);
expect(result.origin).toBe('https://esim.cosr.eu.org');
});
it('rejects OPTIONS preflight for disallowed origin', () => {
const { middleware } = loadModules();
expect(() => middleware.authenticate({
httpMethod: 'OPTIONS',
headers: { origin: 'https://evil.example.com' },
body: '{}',
queryStringParameters: {}
})).toThrow('Origin not allowed');
});
it('does not accept authKey from query or body', () => {
const { middleware } = loadModules();
const event = {
httpMethod: 'POST',
headers: { origin: 'https://esim.cosr.eu.org' },
body: JSON.stringify({ authKey: 'test-access-key' }),
queryStringParameters: { authKey: 'test-access-key' }
};
expect(() => middleware.authenticate(event)).toThrow('Unauthorized');
});
it('uses server redirect URI for token exchange even if client sends another value', async () => {
const { axios, tokenExchange } = loadModules();
jest.spyOn(axios, 'post').mockResolvedValue({
data: { access_token: 'token' }
});
const result = await tokenExchange.handler({
httpMethod: 'POST',
headers: {
origin: 'https://esim.cosr.eu.org',
'x-esim-key': 'test-access-key'
},
body: JSON.stringify({
code: 'authorization-code',
code_verifier: 'a'.repeat(64),
redirect_uri: 'https://attacker.example/callback'
}),
queryStringParameters: {}
}, { functionName: 'giffgaff-token-exchange' });
expect(result.statusCode).toBe(200);
const form = axios.post.mock.calls[0][1];
expect(form).toContain('redirect_uri=giffgaff%3A%2F%2Fauth%2Fcallback%2F');
expect(form).not.toContain('attacker.example');
});
it('throws a configuration error when ACCESS_KEY is missing for token exchange', async () => {
const { tokenExchange } = loadModules({ ACCESS_KEY: '' });
const result = await tokenExchange.handler({
httpMethod: 'POST',
headers: {
origin: 'https://esim.cosr.eu.org'
},
body: JSON.stringify({
code: 'authorization-code',
code_verifier: 'a'.repeat(64)
}),
queryStringParameters: {}
}, { functionName: 'giffgaff-token-exchange' });
expect(result.statusCode).toBe(500);
expect(result.body).toContain('ACCESS_KEY');
});
it('uses configured GIFFGAFF_REDIRECT_URI for token exchange', async () => {
const { axios, tokenExchange } = loadModules({
GIFFGAFF_REDIRECT_URI: 'giffgaff://custom/callback/'
});
jest.spyOn(axios, 'post').mockResolvedValue({
data: { access_token: 'token' }
});
const result = await tokenExchange.handler({
httpMethod: 'POST',
headers: {
origin: 'https://esim.cosr.eu.org',
'x-esim-key': 'test-access-key'
},
body: JSON.stringify({
code: 'authorization-code',
code_verifier: 'a'.repeat(64)
}),
queryStringParameters: {}
}, { functionName: 'giffgaff-token-exchange' });
expect(result.statusCode).toBe(200);
const form = axios.post.mock.calls[0][1];
expect(form).toContain('redirect_uri=giffgaff%3A%2F%2Fcustom%2Fcallback%2F');
});
});