oath grpup fixes
This commit is contained in:
@@ -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
@@ -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'],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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] %>
|
||||
<% } %>
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
Reference in New Issue
Block a user