diff --git a/nodejs/models/oauth_client.js b/nodejs/models/oauth_client.js index f480e0a..cea7bce 100644 --- a/nodejs/models/oauth_client.js +++ b/nodejs/models/oauth_client.js @@ -18,7 +18,8 @@ class OAuthClient extends Table { 'name': {isRequired: true, type: 'string', min: 1, max: 255}, 'description': {default: '', type: 'string'}, 'redirect_uris': {default: [], type: 'object'}, - 'scopes': {default: ['openid', 'profile', 'email'], type: 'object'}, + 'scopes': {default: ['openid', 'profile', 'email', 'groups'], type: 'object'}, + 'allowed_groups': {default: [], type: 'object'}, 'token_lifetime': {default: function(){ return Object.assign({}, defaultLifetime) }, type: 'object'}, 'created_by': {isRequired: true, type: 'string'}, 'created_on': {default: function(){ return (new Date).getTime() }}, diff --git a/nodejs/routes/oauth.js b/nodejs/routes/oauth.js index 2a1d674..b9c06df 100644 --- a/nodejs/routes/oauth.js +++ b/nodejs/routes/oauth.js @@ -8,6 +8,7 @@ const conf = require('@simpleworkjs/conf'); const { OAuthClient } = require('../models/oauth_client'); const { OAuthCode, OAuthAccessToken, OAuthRefreshToken } = require('../models/oauth_code'); const { User } = require('../models/user'); +const { Group } = require('../models/group_ldap'); const oauthConf = conf.oauth || {}; const issuer = oauthConf.issuer || `http://localhost:${conf.port || 3000}`; @@ -56,7 +57,16 @@ function parseClientAuth(req) { }; } -function buildIdToken(user, client, scope, now) { +// The LDAP group CNs a user belongs to (empty on any lookup failure). +async function userGroups(user) { + try { + return await Group.list(user.dn); + } catch(_) { + return []; + } +} + +async function buildIdToken(user, client, scope, now) { const scopes = scope.split(' '); const claims = { iss: issuer, @@ -75,11 +85,14 @@ function buildIdToken(user, client, scope, now) { if (scopes.includes('email')) { claims.email = user.mail; } + if (scopes.includes('groups')) { + claims.groups = await userGroups(user); + } return jwt.sign(claims, jwtSecret, { algorithm: 'HS256' }); } -function userClaims(user, scope) { +async function userClaims(user, scope) { const scopes = scope.split(' '); const claims = { sub: user.uid }; @@ -92,6 +105,9 @@ function userClaims(user, scope) { if (scopes.includes('email')) { claims.email = user.mail; } + if (scopes.includes('groups')) { + claims.groups = await userGroups(user); + } return claims; } @@ -216,7 +232,7 @@ router.post('/token', express.urlencoded({ extended: false }), async function(re }; if (authCode.scope.split(' ').includes('openid')) { - response.id_token = buildIdToken(user, client, authCode.scope, now); + response.id_token = await buildIdToken(user, client, authCode.scope, now); } return res.json(response); @@ -256,7 +272,7 @@ router.post('/token', express.urlencoded({ extended: false }), async function(re }; if (oldRefreshToken.scope.split(' ').includes('openid')) { - response.id_token = buildIdToken(user, client, oldRefreshToken.scope, now); + response.id_token = await buildIdToken(user, client, oldRefreshToken.scope, now); } return res.json(response); @@ -294,7 +310,7 @@ router.get('/userinfo', async function(req, res, next) { } const user = await User.get(accessToken.username); - return res.json(userClaims(user, accessToken.scope)); + return res.json(await userClaims(user, accessToken.scope)); } catch(error) { next(error); } @@ -365,11 +381,28 @@ authRouter.post('/authorize', async function(req, res, next) { return next(makeError('InvalidRedirectURI', 'redirect_uri is not registered for this client.', 400)); } + // Group-based access control: if the client restricts to specific groups, + // only members of at least one of them may obtain an authorization code. + if (client.allowed_groups && client.allowed_groups.length) { + const groups = await userGroups(req.user); + if (!client.allowed_groups.some(g => groups.includes(g))) { + return next(makeError('AccessDenied', 'You are not a member of a group permitted to use this application.', 403)); + } + } + + // Only grant scopes the client is actually registered for (a direct API + // caller could otherwise request scopes the consent screen filtered out). + const grantedScope = (scope || 'openid') + .split(' ') + .filter(Boolean) + .filter(s => client.scopes.includes(s)) + .join(' ') || 'openid'; + const authCode = await OAuthCode.add({ username: req.user.uid, client_id, redirect_uri, - scope: scope || 'openid', + scope: grantedScope, code_challenge: code_challenge || '', code_challenge_method: code_challenge_method || 'S256', }); @@ -394,7 +427,8 @@ function discovery(req, res) { token_endpoint: `${base}/oauth/token`, userinfo_endpoint: `${base}/oauth/userinfo`, end_session_endpoint: `${base}/oauth/logout`, - scopes_supported: ['openid', 'profile', 'email'], + scopes_supported: ['openid', 'profile', 'email', 'groups'], + claims_supported: ['sub', 'preferred_username', 'name', 'given_name', 'family_name', 'email', 'groups'], response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], diff --git a/nodejs/routes/oauth_client.js b/nodejs/routes/oauth_client.js index 6b60c4a..0030d8a 100644 --- a/nodejs/routes/oauth_client.js +++ b/nodejs/routes/oauth_client.js @@ -29,6 +29,10 @@ router.post('/', async function(req, res, next) { if (typeof req.body.scopes === 'string') { req.body.scopes = req.body.scopes.split(' ').map(s => s.trim()).filter(Boolean); } + // Parse allowed_groups if sent as newline-separated string + if (typeof req.body.allowed_groups === 'string') { + req.body.allowed_groups = req.body.allowed_groups.split('\n').map(s => s.trim()).filter(Boolean); + } // jQuery serializeObject sends nested fields as "token_lifetime[access_token]" if (req.body['token_lifetime[access_token]'] || req.body['token_lifetime[refresh_token]']) { req.body.token_lifetime = { @@ -72,6 +76,9 @@ router.put('/:client_id', async function(req, res, next) { if (typeof req.body.scopes === 'string') { req.body.scopes = req.body.scopes.split(' ').map(s => s.trim()).filter(Boolean); } + if (typeof req.body.allowed_groups === 'string') { + req.body.allowed_groups = req.body.allowed_groups.split('\n').map(s => s.trim()).filter(Boolean); + } return res.json({ results: await client.update(req.body), diff --git a/nodejs/tests/oauth.test.js b/nodejs/tests/oauth.test.js index 2115400..713cfb0 100644 --- a/nodejs/tests/oauth.test.js +++ b/nodejs/tests/oauth.test.js @@ -23,7 +23,7 @@ beforeAll(async () => { .send({ name: 'OAuth Flow Test', redirect_uris: REDIRECT_URI, - scopes: 'openid profile email', + scopes: 'openid profile email groups', token_lifetime: { access_token: 3600, refresh_token: 86400 }, }); @@ -381,3 +381,80 @@ describe('OAuth — POST /oauth/token (refresh_token grant)', () => { 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); + }); +}); diff --git a/nodejs/views/oauth_authorize.ejs b/nodejs/views/oauth_authorize.ejs index 09dcc1a..3f128cd 100644 --- a/nodejs/views/oauth_authorize.ejs +++ b/nodejs/views/oauth_authorize.ejs @@ -73,6 +73,8 @@ Read your name and username <% } else if(scopes[i] === 'email'){ %> Read your email address + <% } else if(scopes[i] === 'groups'){ %> + Read your group memberships <% } else { %> <%= scopes[i] %> <% } %> diff --git a/nodejs/views/oauth_clients.ejs b/nodejs/views/oauth_clients.ejs index b266fff..ac3c3be 100644 --- a/nodejs/views/oauth_clients.ejs +++ b/nodejs/views/oauth_clients.ejs @@ -61,6 +61,8 @@ function processClient(client){ client.scopes_display = (client.scopes || []).join(' '); + client.allowed_groups_display = (client.allowed_groups || []).join(', '); + client.has_group_restriction = (client.allowed_groups || []).length > 0; client.access_token_ttl = fmtTTL((client.token_lifetime || {}).access_token || 3600); client.refresh_token_ttl = fmtTTL((client.token_lifetime || {}).refresh_token || 2592000); return client; @@ -156,7 +158,13 @@
{{ scopes_display }}{{ allowed_groups_display }}
+ {{ /has_group_restriction }}
+ {{ ^has_group_restriction }}
+ Any user
+ {{ /has_group_restriction }}
+