Files
sso-manager-node/nodejs/tests/oauth.test.js
T
2026-07-02 16:22:16 -04:00

384 lines
11 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',
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');
});
});