461 lines
14 KiB
JavaScript
461 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
const { login, generatePKCE, request, app } = require('./setup');
|
|
|
|
const REDIRECT_URI = 'https://test.example.com/callback';
|
|
|
|
let token;
|
|
let clientId;
|
|
let clientSecret;
|
|
|
|
// Full OIDC Authorization Code + PKCE flow state
|
|
let authCode;
|
|
let accessToken;
|
|
let refreshToken;
|
|
|
|
beforeAll(async () => {
|
|
token = await login();
|
|
|
|
// Create a dedicated test OAuth client for the flow
|
|
const res = await request(app)
|
|
.post('/api/oauth/client/')
|
|
.set('auth-token', token)
|
|
.send({
|
|
name: 'OAuth Flow Test',
|
|
redirect_uris: REDIRECT_URI,
|
|
scopes: 'openid profile email groups',
|
|
token_lifetime: { access_token: 3600, refresh_token: 86400 },
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Could not create test OAuth client. Is test user in app_sso_oauth_admin? ' + JSON.stringify(res.body));
|
|
}
|
|
|
|
clientId = res.body.results.client_id;
|
|
clientSecret = res.body.client_secret;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (clientId) {
|
|
await request(app)
|
|
.delete(`/api/oauth/client/${clientId}`)
|
|
.set('auth-token', token);
|
|
}
|
|
});
|
|
|
|
describe('OIDC Discovery', () => {
|
|
test('GET /.well-known/openid-configuration returns required fields', async () => {
|
|
const res = await request(app).get('/.well-known/openid-configuration');
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toHaveProperty('issuer');
|
|
expect(res.body).toHaveProperty('authorization_endpoint');
|
|
expect(res.body).toHaveProperty('token_endpoint');
|
|
expect(res.body).toHaveProperty('userinfo_endpoint');
|
|
expect(res.body.response_types_supported).toContain('code');
|
|
expect(res.body.grant_types_supported).toContain('authorization_code');
|
|
expect(res.body.grant_types_supported).toContain('refresh_token');
|
|
expect(res.body.code_challenge_methods_supported).toContain('S256');
|
|
});
|
|
|
|
test('advertises end_session_endpoint', async () => {
|
|
const res = await request(app).get('/.well-known/openid-configuration');
|
|
expect(res.body).toHaveProperty('end_session_endpoint');
|
|
});
|
|
});
|
|
|
|
describe('OAuth — GET /oauth/logout (RP-initiated logout)', () => {
|
|
test('renders logout page with no redirect', async () => {
|
|
const res = await request(app).get('/oauth/logout');
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
test('accepts a post_logout_redirect_uri on a registered client origin', async () => {
|
|
const res = await request(app)
|
|
.get('/oauth/logout')
|
|
.query({ post_logout_redirect_uri: 'https://test.example.com/' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.text).toContain('https://test.example.com/');
|
|
});
|
|
|
|
test('rejects a post_logout_redirect_uri on an unregistered origin', async () => {
|
|
const res = await request(app)
|
|
.get('/oauth/logout')
|
|
.query({ post_logout_redirect_uri: 'https://evil.example.com/' });
|
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
});
|
|
});
|
|
|
|
describe('OAuth — GET /oauth/authorize (consent page validation)', () => {
|
|
test('rejects unknown client_id', async () => {
|
|
const { challenge } = generatePKCE();
|
|
const res = await request(app).get('/oauth/authorize').query({
|
|
response_type: 'code',
|
|
client_id: '00000000-0000-0000-0000-000000000000',
|
|
redirect_uri: REDIRECT_URI,
|
|
scope: 'openid',
|
|
code_challenge: challenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
});
|
|
|
|
test('rejects unregistered redirect_uri', async () => {
|
|
const { challenge } = generatePKCE();
|
|
const res = await request(app).get('/oauth/authorize').query({
|
|
response_type: 'code',
|
|
client_id: clientId,
|
|
redirect_uri: 'https://evil.example.com/callback',
|
|
scope: 'openid',
|
|
code_challenge: challenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
});
|
|
|
|
test('rejects missing code_challenge (PKCE required)', async () => {
|
|
const res = await request(app).get('/oauth/authorize').query({
|
|
response_type: 'code',
|
|
client_id: clientId,
|
|
redirect_uri: REDIRECT_URI,
|
|
scope: 'openid',
|
|
});
|
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
});
|
|
|
|
test('valid params render the consent page', async () => {
|
|
const { challenge } = generatePKCE();
|
|
const res = await request(app).get('/oauth/authorize').query({
|
|
response_type: 'code',
|
|
client_id: clientId,
|
|
redirect_uri: REDIRECT_URI,
|
|
scope: 'openid profile email',
|
|
state: 'teststate',
|
|
code_challenge: challenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
// Returns HTML (the EJS consent page), not an error
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers['content-type']).toMatch(/html/);
|
|
});
|
|
});
|
|
|
|
describe('OAuth — POST /api/oauth/authorize (code issuance)', () => {
|
|
test('issues an authorization code for an authenticated user', async () => {
|
|
const { challenge, verifier } = generatePKCE();
|
|
|
|
const res = await request(app)
|
|
.post('/api/oauth/authorize')
|
|
.set('auth-token', token)
|
|
.send({
|
|
response_type: 'code',
|
|
client_id: clientId,
|
|
redirect_uri: REDIRECT_URI,
|
|
scope: 'openid profile email',
|
|
state: 'teststate',
|
|
code_challenge: challenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toHaveProperty('redirect_url');
|
|
|
|
const redirectUrl = new URL(res.body.redirect_url);
|
|
expect(redirectUrl.searchParams.get('code')).toBeTruthy();
|
|
expect(redirectUrl.searchParams.get('state')).toBe('teststate');
|
|
|
|
// Save for token exchange tests
|
|
authCode = redirectUrl.searchParams.get('code');
|
|
// Also save the verifier so token exchange works
|
|
res._pkceVerifier = verifier;
|
|
|
|
// Store verifier on the module scope for next describe block
|
|
global.__testPkceVerifier = verifier;
|
|
});
|
|
|
|
test('requires auth — 401 without token', async () => {
|
|
const { challenge } = generatePKCE();
|
|
const res = await request(app)
|
|
.post('/api/oauth/authorize')
|
|
.send({
|
|
response_type: 'code',
|
|
client_id: clientId,
|
|
redirect_uri: REDIRECT_URI,
|
|
scope: 'openid',
|
|
code_challenge: challenge,
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('OAuth — POST /oauth/token (authorization_code grant)', () => {
|
|
test('exchanges auth code + PKCE verifier for tokens', async () => {
|
|
expect(authCode).toBeDefined();
|
|
|
|
const res = await request(app)
|
|
.post('/oauth/token')
|
|
.type('form')
|
|
.send({
|
|
grant_type: 'authorization_code',
|
|
code: authCode,
|
|
redirect_uri: REDIRECT_URI,
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
code_verifier: global.__testPkceVerifier,
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toHaveProperty('access_token');
|
|
expect(res.body).toHaveProperty('refresh_token');
|
|
expect(res.body).toHaveProperty('id_token');
|
|
expect(res.body.token_type).toBe('Bearer');
|
|
expect(res.body.expires_in).toBe(3600);
|
|
|
|
accessToken = res.body.access_token;
|
|
refreshToken = res.body.refresh_token;
|
|
});
|
|
|
|
test('rejects reuse of the same auth code', async () => {
|
|
const res = await request(app)
|
|
.post('/oauth/token')
|
|
.type('form')
|
|
.send({
|
|
grant_type: 'authorization_code',
|
|
code: authCode,
|
|
redirect_uri: REDIRECT_URI,
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
code_verifier: global.__testPkceVerifier,
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toBe('invalid_grant');
|
|
});
|
|
|
|
test('rejects wrong code_verifier (PKCE mismatch)', async () => {
|
|
// Get a fresh code first
|
|
const { challenge, verifier } = generatePKCE();
|
|
const codeRes = await request(app)
|
|
.post('/api/oauth/authorize')
|
|
.set('auth-token', token)
|
|
.send({
|
|
response_type: 'code',
|
|
client_id: clientId,
|
|
redirect_uri: REDIRECT_URI,
|
|
scope: 'openid',
|
|
code_challenge: challenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
const freshCode = new URL(codeRes.body.redirect_url).searchParams.get('code');
|
|
|
|
const res = await request(app)
|
|
.post('/oauth/token')
|
|
.type('form')
|
|
.send({
|
|
grant_type: 'authorization_code',
|
|
code: freshCode,
|
|
redirect_uri: REDIRECT_URI,
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
code_verifier: 'wrong-verifier-that-does-not-match',
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toBe('invalid_grant');
|
|
});
|
|
|
|
test('rejects wrong client_secret', async () => {
|
|
const { challenge } = generatePKCE();
|
|
const res = await request(app)
|
|
.post('/oauth/token')
|
|
.type('form')
|
|
.send({
|
|
grant_type: 'authorization_code',
|
|
code: 'doesnotmatter',
|
|
redirect_uri: REDIRECT_URI,
|
|
client_id: clientId,
|
|
client_secret: 'wrong-secret',
|
|
code_verifier: 'doesnotmatter',
|
|
});
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.error).toBe('invalid_client');
|
|
});
|
|
});
|
|
|
|
describe('OAuth — GET /oauth/userinfo', () => {
|
|
test('returns user claims for valid access token', async () => {
|
|
expect(accessToken).toBeDefined();
|
|
|
|
const res = await request(app)
|
|
.get('/oauth/userinfo')
|
|
.set('Authorization', `Bearer ${accessToken}`);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toHaveProperty('sub');
|
|
// profile scope
|
|
expect(res.body).toHaveProperty('name');
|
|
expect(res.body).toHaveProperty('preferred_username');
|
|
// email scope
|
|
expect(res.body).toHaveProperty('email');
|
|
});
|
|
|
|
test('rejects missing Bearer token with 401', async () => {
|
|
const res = await request(app).get('/oauth/userinfo');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
test('rejects invalid Bearer token with 401', async () => {
|
|
const res = await request(app)
|
|
.get('/oauth/userinfo')
|
|
.set('Authorization', 'Bearer not-a-real-token');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('OAuth — POST /oauth/token (refresh_token grant)', () => {
|
|
test('exchanges refresh token for new access + refresh tokens', async () => {
|
|
expect(refreshToken).toBeDefined();
|
|
|
|
const res = await request(app)
|
|
.post('/oauth/token')
|
|
.type('form')
|
|
.send({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toHaveProperty('access_token');
|
|
expect(res.body).toHaveProperty('refresh_token');
|
|
// New tokens should be different (rotation)
|
|
expect(res.body.access_token).not.toBe(accessToken);
|
|
expect(res.body.refresh_token).not.toBe(refreshToken);
|
|
|
|
accessToken = res.body.access_token;
|
|
refreshToken = res.body.refresh_token;
|
|
});
|
|
|
|
test('rejects reuse of the old refresh token after rotation', async () => {
|
|
// Capture the pre-rotation refresh token — it was rotated in the previous test
|
|
// We need a fresh sequence for this test
|
|
const { challenge, verifier } = generatePKCE();
|
|
const codeRes = await request(app)
|
|
.post('/api/oauth/authorize')
|
|
.set('auth-token', token)
|
|
.send({
|
|
response_type: 'code',
|
|
client_id: clientId,
|
|
redirect_uri: REDIRECT_URI,
|
|
scope: 'openid',
|
|
code_challenge: challenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
const freshCode = new URL(codeRes.body.redirect_url).searchParams.get('code');
|
|
|
|
const tokenRes = await request(app)
|
|
.post('/oauth/token')
|
|
.type('form')
|
|
.send({
|
|
grant_type: 'authorization_code',
|
|
code: freshCode,
|
|
redirect_uri: REDIRECT_URI,
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
code_verifier: verifier,
|
|
});
|
|
const oldRefresh = tokenRes.body.refresh_token;
|
|
|
|
// Rotate it once
|
|
await request(app)
|
|
.post('/oauth/token')
|
|
.type('form')
|
|
.send({ grant_type: 'refresh_token', refresh_token: oldRefresh, client_id: clientId, client_secret: clientSecret });
|
|
|
|
// Try to reuse the old one
|
|
const reuseRes = await request(app)
|
|
.post('/oauth/token')
|
|
.type('form')
|
|
.send({ grant_type: 'refresh_token', refresh_token: oldRefresh, client_id: clientId, client_secret: clientSecret });
|
|
|
|
expect(reuseRes.status).toBe(400);
|
|
expect(reuseRes.body.error).toBe('invalid_grant');
|
|
});
|
|
});
|
|
|
|
describe('OAuth — groups claim', () => {
|
|
test('userinfo includes a groups array when the groups scope is granted', async () => {
|
|
const { challenge, verifier } = generatePKCE();
|
|
|
|
const codeRes = await request(app)
|
|
.post('/api/oauth/authorize')
|
|
.set('auth-token', token)
|
|
.send({
|
|
response_type: 'code',
|
|
client_id: clientId,
|
|
redirect_uri: REDIRECT_URI,
|
|
scope: 'openid groups',
|
|
code_challenge: challenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
const code = new URL(codeRes.body.redirect_url).searchParams.get('code');
|
|
|
|
const tokRes = await request(app)
|
|
.post('/oauth/token')
|
|
.type('form')
|
|
.send({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
redirect_uri: REDIRECT_URI,
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
code_verifier: verifier,
|
|
});
|
|
expect(tokRes.status).toBe(200);
|
|
|
|
const uiRes = await request(app)
|
|
.get('/oauth/userinfo')
|
|
.set('Authorization', 'Bearer ' + tokRes.body.access_token);
|
|
expect(uiRes.status).toBe(200);
|
|
expect(Array.isArray(uiRes.body.groups)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('OAuth — allowed_groups access control', () => {
|
|
let restrictedId;
|
|
|
|
beforeAll(async () => {
|
|
const res = await request(app)
|
|
.post('/api/oauth/client/')
|
|
.set('auth-token', token)
|
|
.send({
|
|
name: 'Restricted Group Test',
|
|
redirect_uris: REDIRECT_URI,
|
|
scopes: 'openid',
|
|
allowed_groups: 'this_group_does_not_exist_xyz',
|
|
});
|
|
restrictedId = res.body.results && res.body.results.client_id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (restrictedId) {
|
|
await request(app).delete('/api/oauth/client/' + restrictedId).set('auth-token', token);
|
|
}
|
|
});
|
|
|
|
test('denies a user who is not in any allowed group (403)', async () => {
|
|
const { challenge } = generatePKCE();
|
|
const res = await request(app)
|
|
.post('/api/oauth/authorize')
|
|
.set('auth-token', token)
|
|
.send({
|
|
response_type: 'code',
|
|
client_id: restrictedId,
|
|
redirect_uri: REDIRECT_URI,
|
|
scope: 'openid',
|
|
code_challenge: challenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|