oath grpup fixes

This commit is contained in:
2026-07-02 16:49:22 -04:00
parent 93df047a21
commit bb79247054
6 changed files with 149 additions and 10 deletions
+2 -1
View File
@@ -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() }},
+41 -7
View File
@@ -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'],
+7
View File
@@ -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),
+78 -1
View File
@@ -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);
});
});
+2
View File
@@ -73,6 +73,8 @@
<i class="fa-solid fa-user"></i> Read your name and username
<% } else if(scopes[i] === 'email'){ %>
<i class="fa-solid fa-envelope"></i> Read your email address
<% } else if(scopes[i] === 'groups'){ %>
<i class="fa-solid fa-users"></i> Read your group memberships
<% } else { %>
<i class="fa-solid fa-circle-question"></i> <%= scopes[i] %>
<% } %>
+19 -1
View File
@@ -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 @@
</div>
<div class="mb-3">
<label class="form-label">Scopes <small class="text-muted">(space-separated)</small></label>
<input type="text" class="form-control shadow" name="scopes" value="openid profile email">
<input type="text" class="form-control shadow" name="scopes" value="openid profile email groups">
</div>
<div class="mb-3">
<label class="form-label">Restrict to Groups <small class="text-muted">(one per line, optional)</small></label>
<textarea class="form-control shadow font-monospace" name="allowed_groups" rows="2"
placeholder="app_homeassistant&#10;app_sso_admin"></textarea>
<small class="text-muted">Leave empty to allow any user. If set, only members of a listed LDAP group can log in.</small>
</div>
<div class="row mb-3">
<div class="col">
@@ -209,6 +217,16 @@
</dd>
<dt class="col-sm-3">Scopes</dt>
<dd class="col-sm-9"><code>{{ scopes_display }}</code></dd>
<dt class="col-sm-3">Access</dt>
<dd class="col-sm-9">
{{ #has_group_restriction }}
<span class="badge bg-warning text-dark"><i class="fa-solid fa-user-lock"></i> Restricted</span>
<code>{{ allowed_groups_display }}</code>
{{ /has_group_restriction }}
{{ ^has_group_restriction }}
<span class="badge bg-secondary"><i class="fa-solid fa-users"></i> Any user</span>
{{ /has_group_restriction }}
</dd>
<dt class="col-sm-3">Access Token</dt>
<dd class="col-sm-9">{{ access_token_ttl }}</dd>
<dt class="col-sm-3">Refresh Token</dt>