Files
sso-manager-node/nodejs/tests/oauth.test.js
T
wmantly 7f2c71299f Fix DELETE /api/oauth/client/🆔 client.remove is not a function
OAuthClient wraps @simpleworkjs/orm's Resource model, whose instance
delete method is .delete() -- not .remove(), which is what model-redis's
Table instances (e.g. this app's ApiToken, AuthToken) use. The DELETE
route called the wrong one, so every delete silently 500'd; the route's
try/catch turned it into a plain JSON error response rather than a thrown
exception, and the existing tests' cleanup-only delete calls (afterAll,
end of the rotate test) never checked the response status, so the bug
shipped unnoticed. The Directory Management UI was never affected --
routes/api_directory_admin.js's DELETE routes already used .delete()
correctly throughout.

Found and root-caused live against a real deployment's SSO API, then
reproduced and fixed against a local docker stack with a rebuilt image:
confirmed DELETE returned a genuine 500 before the fix and a real 200 +
404-on-subsequent-GET after.

Adds two dedicated tests (PUT and DELETE persistence, each verified by a
follow-up GET rather than trusting the mutating response alone), and
hardens the existing rotate test's incidental delete call with real
assertions. Verified the new DELETE test fails on the old code and
passes on the fix. Full suite (189 tests, real LDAP + Redis) passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 21:03:34 -04:00

569 lines
18 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('OAuth client management API — /api/oauth/client', () => {
// Regression: the ORM Model.toJSON() strips non-schema fields, so the
// mapped client_id/scopes/etc. used to vanish from GET responses —
// client_id came back undefined and the theta-env bootstrap's rotate
// crashed with a 500. GET must expose client_id (and never the secret hash).
test('GET / list exposes client_id and hides client_secret_hash', async () => {
const res = await request(app)
.get('/api/oauth/client/')
.set('auth-token', token);
expect(res.status).toBe(200);
const mine = res.body.results.find((c) => c.client_id === clientId);
expect(mine).toBeDefined();
expect(mine.client_id).toBe(clientId);
expect(mine).toHaveProperty('scopes');
expect(mine).not.toHaveProperty('client_secret_hash');
});
test('GET /:id exposes client_id', async () => {
const res = await request(app)
.get(`/api/oauth/client/${clientId}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body.results.client_id).toBe(clientId);
expect(res.body.results).not.toHaveProperty('client_secret_hash');
});
test('PUT persists — a changed name survives a fresh GET', async () => {
const created = await request(app)
.post('/api/oauth/client/')
.set('auth-token', token)
.send({ name: 'put-persist-test', redirect_uris: REDIRECT_URI });
expect(created.status).toBe(200);
const id = created.body.results.client_id;
const updated = await request(app)
.put(`/api/oauth/client/${id}`)
.set('auth-token', token)
.send({ name: 'put-persist-test-renamed' });
expect(updated.status).toBe(200);
expect(updated.body.results.name).toBe('put-persist-test-renamed');
const fetched = await request(app).get(`/api/oauth/client/${id}`).set('auth-token', token);
expect(fetched.status).toBe(200);
expect(fetched.body.results.name).toBe('put-persist-test-renamed');
await request(app).delete(`/api/oauth/client/${id}`).set('auth-token', token);
});
// Regression: this route called client.remove(), but OAuthClient wraps
// @simpleworkjs/orm's Resource model, whose instance method is .delete()
// — .remove() doesn't exist on it (unlike the model-redis Tables
// elsewhere in this app, e.g. api_token.js, which really do have
// .remove()). The route's try/catch turned the resulting TypeError into
// a plain 500 JSON response rather than a thrown exception, so every
// prior DELETE call in this file's cleanup hooks silently "succeeded"
// from Jest's point of view while leaving the client un-deleted.
test('DELETE persists — the client is actually gone, not just a 200', async () => {
const created = await request(app)
.post('/api/oauth/client/')
.set('auth-token', token)
.send({ name: 'delete-persist-test', redirect_uris: REDIRECT_URI });
expect(created.status).toBe(200);
const id = created.body.results.client_id;
const deleted = await request(app).delete(`/api/oauth/client/${id}`).set('auth-token', token);
expect(deleted.status).toBe(200);
const fetched = await request(app).get(`/api/oauth/client/${id}`).set('auth-token', token);
expect(fetched.status).toBe(404);
});
test('list then rotate a client by its returned client_id (the bootstrap path)', async () => {
// Reproduces exactly what the theta-env bootstrap does: create, list,
// find by name, rotate by the client_id from the list response. Uses a
// throwaway client so the shared flow client's secret is untouched.
const created = await request(app)
.post('/api/oauth/client/')
.set('auth-token', token)
.send({ name: 'rotate-regression', redirect_uris: REDIRECT_URI });
expect(created.status).toBe(200);
const list = await request(app).get('/api/oauth/client/').set('auth-token', token);
const found = list.body.results.find((c) => c.name === 'rotate-regression');
expect(found).toBeDefined();
expect(found.client_id).toBeTruthy(); // was undefined before the fix
const rotated = await request(app)
.post(`/api/oauth/client/${found.client_id}/rotate`)
.set('auth-token', token);
expect(rotated.status).toBe(200);
expect(rotated.body.client_secret).toBeTruthy();
const deleted = await request(app).delete(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
expect(deleted.status).toBe(200);
const afterDelete = await request(app).get(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
expect(afterDelete.status).toBe(404);
});
test('GET /:id unknown id returns 404, not 500', async () => {
const res = await request(app)
.get('/api/oauth/client/00000000-0000-0000-0000-000000000000')
.set('auth-token', token);
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(500);
});
});
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);
});
});