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},
|
'name': {isRequired: true, type: 'string', min: 1, max: 255},
|
||||||
'description': {default: '', type: 'string'},
|
'description': {default: '', type: 'string'},
|
||||||
'redirect_uris': {default: [], type: 'object'},
|
'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'},
|
'token_lifetime': {default: function(){ return Object.assign({}, defaultLifetime) }, type: 'object'},
|
||||||
'created_by': {isRequired: true, type: 'string'},
|
'created_by': {isRequired: true, type: 'string'},
|
||||||
'created_on': {default: function(){ return (new Date).getTime() }},
|
'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 { OAuthClient } = require('../models/oauth_client');
|
||||||
const { OAuthCode, OAuthAccessToken, OAuthRefreshToken } = require('../models/oauth_code');
|
const { OAuthCode, OAuthAccessToken, OAuthRefreshToken } = require('../models/oauth_code');
|
||||||
const { User } = require('../models/user');
|
const { User } = require('../models/user');
|
||||||
|
const { Group } = require('../models/group_ldap');
|
||||||
|
|
||||||
const oauthConf = conf.oauth || {};
|
const oauthConf = conf.oauth || {};
|
||||||
const issuer = oauthConf.issuer || `http://localhost:${conf.port || 3000}`;
|
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 scopes = scope.split(' ');
|
||||||
const claims = {
|
const claims = {
|
||||||
iss: issuer,
|
iss: issuer,
|
||||||
@@ -75,11 +85,14 @@ function buildIdToken(user, client, scope, now) {
|
|||||||
if (scopes.includes('email')) {
|
if (scopes.includes('email')) {
|
||||||
claims.email = user.mail;
|
claims.email = user.mail;
|
||||||
}
|
}
|
||||||
|
if (scopes.includes('groups')) {
|
||||||
|
claims.groups = await userGroups(user);
|
||||||
|
}
|
||||||
|
|
||||||
return jwt.sign(claims, jwtSecret, { algorithm: 'HS256' });
|
return jwt.sign(claims, jwtSecret, { algorithm: 'HS256' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function userClaims(user, scope) {
|
async function userClaims(user, scope) {
|
||||||
const scopes = scope.split(' ');
|
const scopes = scope.split(' ');
|
||||||
const claims = { sub: user.uid };
|
const claims = { sub: user.uid };
|
||||||
|
|
||||||
@@ -92,6 +105,9 @@ function userClaims(user, scope) {
|
|||||||
if (scopes.includes('email')) {
|
if (scopes.includes('email')) {
|
||||||
claims.email = user.mail;
|
claims.email = user.mail;
|
||||||
}
|
}
|
||||||
|
if (scopes.includes('groups')) {
|
||||||
|
claims.groups = await userGroups(user);
|
||||||
|
}
|
||||||
|
|
||||||
return claims;
|
return claims;
|
||||||
}
|
}
|
||||||
@@ -216,7 +232,7 @@ router.post('/token', express.urlencoded({ extended: false }), async function(re
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (authCode.scope.split(' ').includes('openid')) {
|
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);
|
return res.json(response);
|
||||||
@@ -256,7 +272,7 @@ router.post('/token', express.urlencoded({ extended: false }), async function(re
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (oldRefreshToken.scope.split(' ').includes('openid')) {
|
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);
|
return res.json(response);
|
||||||
@@ -294,7 +310,7 @@ router.get('/userinfo', async function(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.get(accessToken.username);
|
const user = await User.get(accessToken.username);
|
||||||
return res.json(userClaims(user, accessToken.scope));
|
return res.json(await userClaims(user, accessToken.scope));
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
next(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));
|
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({
|
const authCode = await OAuthCode.add({
|
||||||
username: req.user.uid,
|
username: req.user.uid,
|
||||||
client_id,
|
client_id,
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
scope: scope || 'openid',
|
scope: grantedScope,
|
||||||
code_challenge: code_challenge || '',
|
code_challenge: code_challenge || '',
|
||||||
code_challenge_method: code_challenge_method || 'S256',
|
code_challenge_method: code_challenge_method || 'S256',
|
||||||
});
|
});
|
||||||
@@ -394,7 +427,8 @@ function discovery(req, res) {
|
|||||||
token_endpoint: `${base}/oauth/token`,
|
token_endpoint: `${base}/oauth/token`,
|
||||||
userinfo_endpoint: `${base}/oauth/userinfo`,
|
userinfo_endpoint: `${base}/oauth/userinfo`,
|
||||||
end_session_endpoint: `${base}/oauth/logout`,
|
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'],
|
response_types_supported: ['code'],
|
||||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||||
code_challenge_methods_supported: ['S256'],
|
code_challenge_methods_supported: ['S256'],
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ router.post('/', async function(req, res, next) {
|
|||||||
if (typeof req.body.scopes === 'string') {
|
if (typeof req.body.scopes === 'string') {
|
||||||
req.body.scopes = req.body.scopes.split(' ').map(s => s.trim()).filter(Boolean);
|
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]"
|
// jQuery serializeObject sends nested fields as "token_lifetime[access_token]"
|
||||||
if (req.body['token_lifetime[access_token]'] || req.body['token_lifetime[refresh_token]']) {
|
if (req.body['token_lifetime[access_token]'] || req.body['token_lifetime[refresh_token]']) {
|
||||||
req.body.token_lifetime = {
|
req.body.token_lifetime = {
|
||||||
@@ -72,6 +76,9 @@ router.put('/:client_id', async function(req, res, next) {
|
|||||||
if (typeof req.body.scopes === 'string') {
|
if (typeof req.body.scopes === 'string') {
|
||||||
req.body.scopes = req.body.scopes.split(' ').map(s => s.trim()).filter(Boolean);
|
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({
|
return res.json({
|
||||||
results: await client.update(req.body),
|
results: await client.update(req.body),
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ beforeAll(async () => {
|
|||||||
.send({
|
.send({
|
||||||
name: 'OAuth Flow Test',
|
name: 'OAuth Flow Test',
|
||||||
redirect_uris: REDIRECT_URI,
|
redirect_uris: REDIRECT_URI,
|
||||||
scopes: 'openid profile email',
|
scopes: 'openid profile email groups',
|
||||||
token_lifetime: { access_token: 3600, refresh_token: 86400 },
|
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');
|
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
|
<i class="fa-solid fa-user"></i> Read your name and username
|
||||||
<% } else if(scopes[i] === 'email'){ %>
|
<% } else if(scopes[i] === 'email'){ %>
|
||||||
<i class="fa-solid fa-envelope"></i> Read your email address
|
<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 { %>
|
<% } else { %>
|
||||||
<i class="fa-solid fa-circle-question"></i> <%= scopes[i] %>
|
<i class="fa-solid fa-circle-question"></i> <%= scopes[i] %>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|||||||
@@ -61,6 +61,8 @@
|
|||||||
|
|
||||||
function processClient(client){
|
function processClient(client){
|
||||||
client.scopes_display = (client.scopes || []).join(' ');
|
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.access_token_ttl = fmtTTL((client.token_lifetime || {}).access_token || 3600);
|
||||||
client.refresh_token_ttl = fmtTTL((client.token_lifetime || {}).refresh_token || 2592000);
|
client.refresh_token_ttl = fmtTTL((client.token_lifetime || {}).refresh_token || 2592000);
|
||||||
return client;
|
return client;
|
||||||
@@ -156,7 +158,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Scopes <small class="text-muted">(space-separated)</small></label>
|
<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>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@@ -209,6 +217,16 @@
|
|||||||
</dd>
|
</dd>
|
||||||
<dt class="col-sm-3">Scopes</dt>
|
<dt class="col-sm-3">Scopes</dt>
|
||||||
<dd class="col-sm-9"><code>{{ scopes_display }}</code></dd>
|
<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>
|
<dt class="col-sm-3">Access Token</dt>
|
||||||
<dd class="col-sm-9">{{ access_token_ttl }}</dd>
|
<dd class="col-sm-9">{{ access_token_ttl }}</dd>
|
||||||
<dt class="col-sm-3">Refresh Token</dt>
|
<dt class="col-sm-3">Refresh Token</dt>
|
||||||
|
|||||||
Reference in New Issue
Block a user