Merge pull request #54 from theta42/integrations-page-and-service-accounts
Merge OAuth Apps + LDAP Info into one page; add Service Accounts; document LDAPS exposure
This commit is contained in:
+8
-1
@@ -456,6 +456,13 @@ netstat -tlnp | grep 389
|
|||||||
set `JWT_SECRET`, issued tokens invalidate on container recreation.
|
set `JWT_SECRET`, issued tokens invalidate on container recreation.
|
||||||
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front
|
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front
|
||||||
proxy and keep `3001` on the Docker network / localhost only.
|
proxy and keep `3001` on the Docker network / localhost only.
|
||||||
5. The all-in-one image runs slapd as the `ldap` user but the app process as root
|
5. **Don't port-forward LDAPS (636) to the internet either.** It's mapped to the
|
||||||
|
host by default for LAN/VPN clients that bind LDAP directly (other hosts
|
||||||
|
running `ldap-client`, apps with their own LDAP auth settings) — not for
|
||||||
|
exposure through your router/firewall. LDAP simple-bind is a brute-force
|
||||||
|
target with no rate limiting in front of it the way the HTTP login endpoints
|
||||||
|
have. If a remote host needs to bind LDAP, put it behind a VPN (Tailscale,
|
||||||
|
WireGuard, …) instead of forwarding 636 publicly.
|
||||||
|
6. The all-in-one image runs slapd as the `ldap` user but the app process as root
|
||||||
(matches the bare-metal systemd unit). Harden the app to a non-root user for
|
(matches the bare-metal systemd unit). Harden the app to a non-root user for
|
||||||
production if needed.
|
production if needed.
|
||||||
+9
-1
@@ -203,7 +203,15 @@ automates all four steps with `./setup.sh` — see
|
|||||||
container recreation.
|
container recreation.
|
||||||
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a
|
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a
|
||||||
front proxy and keep `3001` on the Docker network / localhost only.
|
front proxy and keep `3001` on the Docker network / localhost only.
|
||||||
5. The all-in-one image runs slapd as the `ldap` user but the app as root
|
5. **Don't port-forward LDAPS (636) to the internet either.** It's mapped to
|
||||||
|
the host by default for LAN/VPN clients that bind LDAP directly (other
|
||||||
|
hosts running `ldap-client`, apps with their own LDAP auth settings) — not
|
||||||
|
for exposure through your router/firewall. LDAP simple-bind is a
|
||||||
|
brute-force target and there's no rate limiting in front of it the way
|
||||||
|
there is for the HTTP login endpoints. If you need a remote host to bind
|
||||||
|
LDAP, put it behind a VPN (Tailscale, WireGuard, …) instead of forwarding
|
||||||
|
636 publicly.
|
||||||
|
6. The all-in-one image runs slapd as the `ldap` user but the app as root
|
||||||
(matches the bare-metal unit). Harden to a non-root user for production.
|
(matches the bare-metal unit). Harden to a non-root user for production.
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
[← Back to Home](index.html)
|
||||||
@@ -74,6 +74,7 @@ app.use('/api/user', middleware.auth, require('./routes/user'));
|
|||||||
app.use('/api/token', middleware.auth, require('./routes/token'));
|
app.use('/api/token', middleware.auth, require('./routes/token'));
|
||||||
|
|
||||||
app.use('/api/group', middleware.auth, require('./routes/group'));
|
app.use('/api/group', middleware.auth, require('./routes/group'));
|
||||||
|
app.use('/api/service-account', middleware.auth, require('./routes/service_account'));
|
||||||
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
||||||
|
|
||||||
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Non-person "service" accounts under ou=people -- bind-only LDAP identities
|
||||||
|
// for things like theta-env's bootstrap-created cn=ldapclient (the proxy's
|
||||||
|
// direct-LDAP bind account) or any other app/host that needs its own
|
||||||
|
// dedicated read-only credential, as opposed to a real user who logs into
|
||||||
|
// the web UI.
|
||||||
|
//
|
||||||
|
// Deliberately NOT posixAccount/inetOrgPerson (the User model's shape) --
|
||||||
|
// these can't log into the SSO Manager UI or get a home directory/uidNumber.
|
||||||
|
// objectClass matches exactly what theta-env's bootstrap.js already creates
|
||||||
|
// for cn=ldapclient, so this model recognizes and manages that account too,
|
||||||
|
// not just ones created through this UI.
|
||||||
|
|
||||||
|
const { Client, Attribute, Change } = require('ldapts');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const conf = require('@simpleworkjs/conf').ldap;
|
||||||
|
|
||||||
|
function hashPasswordSSHA512(password) {
|
||||||
|
const salt = crypto.randomBytes(8);
|
||||||
|
const hash = crypto.createHash('sha512').update(password).update(salt).digest();
|
||||||
|
return '{SSHA512}' + Buffer.concat([hash, salt]).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeClient() {
|
||||||
|
return new Client({ url: conf.url });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withClient(fn) {
|
||||||
|
const client = makeClient();
|
||||||
|
try {
|
||||||
|
await client.bind(conf.bindDN, conf.bindPassword);
|
||||||
|
return await fn(client);
|
||||||
|
} finally {
|
||||||
|
await client.unbind().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER = '(&(objectClass=organizationalRole)(objectClass=simpleSecurityObject))';
|
||||||
|
const CN_RE = /^[A-Za-z][A-Za-z0-9._-]{1,63}$/;
|
||||||
|
|
||||||
|
var ServiceAccount = {};
|
||||||
|
|
||||||
|
ServiceAccount.list = async function(){
|
||||||
|
return withClient(async (client) => {
|
||||||
|
const res = await client.search(conf.userBase, {
|
||||||
|
scope: 'sub',
|
||||||
|
filter: FILTER,
|
||||||
|
attributes: ['cn', 'description', 'createTimestamp', 'modifyTimestamp'],
|
||||||
|
});
|
||||||
|
return res.searchEntries.map((entry) => ({
|
||||||
|
cn: entry.cn,
|
||||||
|
dn: `cn=${entry.cn},${conf.userBase}`,
|
||||||
|
description: entry.description || '',
|
||||||
|
created_on: entry.createTimestamp || null,
|
||||||
|
modified_on: entry.modifyTimestamp || null,
|
||||||
|
})).sort((a, b) => a.cn.localeCompare(b.cn));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ServiceAccount.create = async function({cn, description}){
|
||||||
|
if(!cn || !CN_RE.test(cn)){
|
||||||
|
throw Object.assign(new Error('InvalidName'), {status: 400, message: 'Name must start with a letter and contain only letters, numbers, dot, dash, underscore.'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dn = `cn=${cn},${conf.userBase}`;
|
||||||
|
const password = crypto.randomBytes(24).toString('base64url');
|
||||||
|
|
||||||
|
await withClient(async (client) => {
|
||||||
|
let existing = true;
|
||||||
|
try{
|
||||||
|
const res = await client.search(dn, {scope: 'base', filter: '(objectClass=*)', attributes: ['dn']});
|
||||||
|
existing = res.searchEntries.length > 0;
|
||||||
|
}catch(error){ existing = false; }
|
||||||
|
if(existing){
|
||||||
|
throw Object.assign(new Error('NameInUse'), {status: 409, message: `"${cn}" already exists under ${conf.userBase}.`});
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.add(dn, {
|
||||||
|
objectClass: ['organizationalRole', 'simpleSecurityObject', 'top'],
|
||||||
|
cn,
|
||||||
|
description: description || '',
|
||||||
|
userPassword: hashPasswordSSHA512(password),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {cn, dn, description: description || '', password};
|
||||||
|
};
|
||||||
|
|
||||||
|
ServiceAccount.setPassword = async function(cn, password){
|
||||||
|
const dn = `cn=${cn},${conf.userBase}`;
|
||||||
|
const newPassword = password || crypto.randomBytes(24).toString('base64url');
|
||||||
|
|
||||||
|
await withClient(async (client) => {
|
||||||
|
await client.modify(dn, [
|
||||||
|
new Change({
|
||||||
|
operation: 'replace',
|
||||||
|
modification: new Attribute({type: 'userPassword', values: [hashPasswordSSHA512(newPassword)]}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {cn, dn, password: newPassword};
|
||||||
|
};
|
||||||
|
|
||||||
|
ServiceAccount.remove = async function(cn){
|
||||||
|
const dn = `cn=${cn},${conf.userBase}`;
|
||||||
|
await withClient(async (client) => {
|
||||||
|
await client.del(dn);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {ServiceAccount};
|
||||||
+10
-10
@@ -77,15 +77,11 @@ router.get('/login', async function(req, res, next) {
|
|||||||
res.render('login', {...values, redirect: req.query.redirect});
|
res.render('login', {...values, redirect: req.query.redirect});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/oauth-clients', function(req, res, next) {
|
// OAuth client management and LDAP connection info, merged into one page
|
||||||
const issuer = ((conf.oauth && conf.oauth.issuer) || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
|
// (tabs) -- both are "how do other apps/hosts plug into this SSO" concerns.
|
||||||
res.render('oauth_clients', {...values, issuer, discoveryUrl: `${issuer}/.well-known/openid-configuration`});
|
// LDAP values are derived from the running config + request host rather than
|
||||||
});
|
// hardcoded in a doc, so they're always right for *this* deployment.
|
||||||
|
router.get('/integrations', function(req, res, next) {
|
||||||
// Everything a 3rd-party app or the ldap-client host script needs to bind
|
|
||||||
// this directory, derived from the running config + request host rather than
|
|
||||||
// hardcoded in a doc -- so it's always right for *this* deployment.
|
|
||||||
router.get('/ldap-info', function(req, res, next) {
|
|
||||||
const issuer = ((conf.oauth && conf.oauth.issuer) || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
|
const issuer = ((conf.oauth && conf.oauth.issuer) || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
|
||||||
const ldapHost = issuer.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
|
const ldapHost = issuer.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
|
||||||
|
|
||||||
@@ -96,8 +92,10 @@ router.get('/ldap-info', function(req, res, next) {
|
|||||||
// -> dc=example,dc=com).
|
// -> dc=example,dc=com).
|
||||||
const baseDn = userBase.replace(/^ou=[^,]+,/i, '');
|
const baseDn = userBase.replace(/^ou=[^,]+,/i, '');
|
||||||
|
|
||||||
res.render('ldap_info', {
|
res.render('integrations', {
|
||||||
...values,
|
...values,
|
||||||
|
issuer,
|
||||||
|
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
|
||||||
ldapHost,
|
ldapHost,
|
||||||
ldapsUrl: `ldaps://${ldapHost}:636`,
|
ldapsUrl: `ldaps://${ldapHost}:636`,
|
||||||
baseDn,
|
baseDn,
|
||||||
@@ -109,6 +107,8 @@ router.get('/ldap-info', function(req, res, next) {
|
|||||||
ssoUrl: issuer,
|
ssoUrl: issuer,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
router.get('/oauth-clients', (req, res) => res.redirect(301, '/integrations'));
|
||||||
|
router.get('/ldap-info', (req, res) => res.redirect(301, '/integrations'));
|
||||||
|
|
||||||
// API Tokens is now a section on the Profile page (own profile only).
|
// API Tokens is now a section on the Profile page (own profile only).
|
||||||
router.get('/api-tokens', (req, res) => res.redirect(301, '/'));
|
router.get('/api-tokens', (req, res) => res.redirect(301, '/'));
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const {ServiceAccount} = require('../models/service_account');
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
|
||||||
|
const ADMIN_GROUP = 'app_sso_admin';
|
||||||
|
|
||||||
|
router.get('/', async function(req, res, next) {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||||
|
return res.json({results: await ServiceAccount.list()});
|
||||||
|
} catch(error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async function(req, res, next) {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||||
|
const result = await ServiceAccount.create({cn: req.body.cn, description: req.body.description});
|
||||||
|
return res.json({
|
||||||
|
results: result,
|
||||||
|
message: `Service account "${result.cn}" created. Save the password now — it will not be shown again.`,
|
||||||
|
});
|
||||||
|
} catch(error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:cn/password', async function(req, res, next) {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||||
|
const result = await ServiceAccount.setPassword(req.params.cn, req.body.password);
|
||||||
|
return res.json({
|
||||||
|
results: result,
|
||||||
|
message: `Password rotated for "${req.params.cn}". Save it now — it will not be shown again.`,
|
||||||
|
});
|
||||||
|
} catch(error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:cn', async function(req, res, next) {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||||
|
await ServiceAccount.remove(req.params.cn);
|
||||||
|
return res.json({message: `Service account "${req.params.cn}" deleted.`});
|
||||||
|
} catch(error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,633 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<!-- Edit OAuth client modal -->
|
||||||
|
<div class="modal fade" id="editModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="fa-solid fa-pen-to-square"></i> Edit OAuth Client</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="card-header actionMessage mb-3" style="display:none"></div>
|
||||||
|
<input type="hidden" id="edit-client-id">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" id="edit-name" class="form-control shadow">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input type="text" id="edit-description" class="form-control shadow">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
||||||
|
<textarea id="edit-redirect_uris" class="form-control shadow font-monospace" rows="3"></textarea>
|
||||||
|
<small class="field-help text-muted d-block">
|
||||||
|
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Scopes</label>
|
||||||
|
<div id="edit-scopes"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
|
||||||
|
<div id="edit-allowed_groups"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
||||||
|
<input type="number" id="edit-access_ttl" class="form-control shadow" min="60">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
||||||
|
<input type="number" id="edit-refresh_ttl" class="form-control shadow" min="3600">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveEdit(this)"><i class="fa-solid fa-floppy-disk"></i> Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Secret modal — shared by OAuth client secrets and service account passwords -->
|
||||||
|
<div class="modal fade" id="secretModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="secretModalTitle"><i class="fa-solid fa-key"></i> Secret</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this now — it will <strong>not</strong> be shown again.</p>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" id="secretValue" class="form-control font-monospace" readonly>
|
||||||
|
<button class="btn btn-outline-secondary" onclick="copySecret()" title="Copy">
|
||||||
|
<i class="fa-solid fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
app.auth.forceLogin(['app_sso_admin', 'app_sso_oauth_admin']);
|
||||||
|
|
||||||
|
// The scopes this provider actually understands (see routes/oauth.js discovery).
|
||||||
|
var VALID_SCOPES = ['openid', 'profile', 'email', 'groups'];
|
||||||
|
var DEFAULT_SCOPES = ['openid', 'profile', 'email', 'groups'];
|
||||||
|
|
||||||
|
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
|
||||||
|
var editModal = new bootstrap.Modal(document.getElementById('editModal'));
|
||||||
|
|
||||||
|
// Widget handles + a lookup of the latest client data (for the edit modal).
|
||||||
|
var createScopes, createGroups, editScopes, editGroups;
|
||||||
|
var clientsById = {};
|
||||||
|
|
||||||
|
function showSecret(secret, title){
|
||||||
|
document.getElementById('secretModalTitle').innerHTML = '<i class="fa-solid fa-key"></i> ' + (title || 'Secret');
|
||||||
|
document.getElementById('secretValue').value = secret;
|
||||||
|
secretModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copySecret(){
|
||||||
|
copyField('secretValue');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the value of an input by id; briefly flips the button icon to a check.
|
||||||
|
function copyField(id, btn){
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if(!el) return;
|
||||||
|
el.select();
|
||||||
|
el.setSelectionRange(0, 99999);
|
||||||
|
document.execCommand('copy');
|
||||||
|
if(btn){
|
||||||
|
var $i = $(btn).find('i');
|
||||||
|
var prev = $i.attr('class');
|
||||||
|
$i.attr('class', 'fa-solid fa-check');
|
||||||
|
setTimeout(function(){ $i.attr('class', prev); }, 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTTL(seconds){
|
||||||
|
if(seconds < 3600) return seconds + 's';
|
||||||
|
if(seconds < 86400) return (seconds / 3600).toFixed(1) + 'h';
|
||||||
|
return (seconds / 86400).toFixed(1) + 'd';
|
||||||
|
}
|
||||||
|
|
||||||
|
function processClient(client){
|
||||||
|
clientsById[client.client_id] = client; // keep raw data for the edit modal
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tableAJAX(){
|
||||||
|
let data = await app.oauthClient.list();
|
||||||
|
$.scope.oauthClientCard.empty();
|
||||||
|
$.each(data.results, function(_, client){
|
||||||
|
$.scope.oauthClientCard.push(processClient(client));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteClient(client_id, name, btn){
|
||||||
|
const $card = $(btn).closest('.card');
|
||||||
|
$card.addClass('table-warning');
|
||||||
|
const confirmed = await app.util.actionConfirm('Delete OAuth client "' + name + '"?', $card, 'warning');
|
||||||
|
$card.removeClass('table-warning');
|
||||||
|
if (!confirmed) return;
|
||||||
|
app.api.delete('oauth/client/' + client_id, function(error, data){
|
||||||
|
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
||||||
|
$.scope.oauthClientCard.remove('client_id', client_id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateSecret(client_id, name, btn){
|
||||||
|
const $card = $(btn).closest('.card');
|
||||||
|
const confirmed = await app.util.actionConfirm('Rotate secret for "' + name + '"? The old secret will stop working immediately.', $card, 'warning');
|
||||||
|
if (!confirmed) return;
|
||||||
|
app.oauthClient.rotateSecret({client_id: client_id}, function(error, data){
|
||||||
|
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
||||||
|
showSecret(data.client_secret, 'Client Secret');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the edit modal pre-filled from the client's current values.
|
||||||
|
function editClient(client_id){
|
||||||
|
var c = clientsById[client_id];
|
||||||
|
if(!c) return;
|
||||||
|
$('#edit-client-id').val(client_id);
|
||||||
|
$('#edit-name').val(c.name || '');
|
||||||
|
$('#edit-description').val(c.description || '');
|
||||||
|
$('#edit-redirect_uris').val((c.redirect_uris || []).join('\n'));
|
||||||
|
$('#edit-access_ttl').val((c.token_lifetime || {}).access_token || 3600);
|
||||||
|
$('#edit-refresh_ttl').val((c.token_lifetime || {}).refresh_token || 2592000);
|
||||||
|
|
||||||
|
// (Re)build the tag widgets fresh each open so they reflect this client.
|
||||||
|
editScopes = app.ui.tagInput('#edit-scopes', {
|
||||||
|
values: c.scopes || [], options: VALID_SCOPES, freeSolo: false,
|
||||||
|
separator: ' ', placeholder: 'Add a scope…',
|
||||||
|
});
|
||||||
|
editGroups = app.ui.groupSelect('#edit-allowed_groups', {
|
||||||
|
values: c.allowed_groups || [], placeholder: 'Type a group name…',
|
||||||
|
});
|
||||||
|
editModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEdit(btn){
|
||||||
|
var $msg = $('#editModal .actionMessage');
|
||||||
|
var payload = {
|
||||||
|
client_id: $('#edit-client-id').val(),
|
||||||
|
name: $('#edit-name').val(),
|
||||||
|
description: $('#edit-description').val(),
|
||||||
|
redirect_uris: $('#edit-redirect_uris').val().split('\n').map(function(s){ return s.trim(); }).filter(Boolean),
|
||||||
|
scopes: editScopes.get(),
|
||||||
|
allowed_groups: editGroups.get(),
|
||||||
|
token_lifetime: {
|
||||||
|
access_token: Number($('#edit-access_ttl').val()) || 3600,
|
||||||
|
refresh_token: Number($('#edit-refresh_ttl').val()) || 2592000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
app.oauthClient.update(payload, function(error, data){
|
||||||
|
if(error){
|
||||||
|
app.util.actionMessage((data && data.message) || 'Update failed.', $msg.parent(), 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editModal.hide();
|
||||||
|
tableAJAX();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Service accounts ──────────────────────────────────────────────────
|
||||||
|
async function svcTableAJAX(){
|
||||||
|
let data = await app.api.get('service-account');
|
||||||
|
$.scope.serviceAccountCard.empty();
|
||||||
|
$.each(data.results, function(_, acct){
|
||||||
|
$.scope.serviceAccountCard.push(acct);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateServiceAccountPassword(cn, btn){
|
||||||
|
const $card = $(btn).closest('.card');
|
||||||
|
const confirmed = await app.util.actionConfirm('Rotate the password for "' + cn + '"? Anything still using the old password will stop working immediately.', $card, 'warning');
|
||||||
|
if (!confirmed) return;
|
||||||
|
app.api.put('service-account/' + encodeURIComponent(cn) + '/password', {}, function(error, data){
|
||||||
|
if(error){ app.util.actionMessage('Error: ' + (data && data.message), $card, 'danger'); return; }
|
||||||
|
showSecret(data.results.password, 'Password for ' + cn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteServiceAccount(cn, btn){
|
||||||
|
const $card = $(btn).closest('.card');
|
||||||
|
$card.addClass('table-warning');
|
||||||
|
const confirmed = await app.util.actionConfirm('Delete service account "' + cn + '"? Anything binding as it will stop working immediately.', $card, 'warning');
|
||||||
|
$card.removeClass('table-warning');
|
||||||
|
if (!confirmed) return;
|
||||||
|
app.api.delete('service-account/' + encodeURIComponent(cn), function(error, data){
|
||||||
|
if(error){ app.util.actionMessage('Error: ' + (data && data.message), $card, 'danger'); return; }
|
||||||
|
$.scope.serviceAccountCard.remove('cn', cn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function(){
|
||||||
|
tableAJAX();
|
||||||
|
svcTableAJAX();
|
||||||
|
|
||||||
|
// Initialise the create-form tag widgets.
|
||||||
|
createScopes = app.ui.tagInput('#create-scopes', {
|
||||||
|
name: 'scopes', values: DEFAULT_SCOPES, options: VALID_SCOPES,
|
||||||
|
freeSolo: false, separator: ' ', placeholder: 'Add a scope…',
|
||||||
|
});
|
||||||
|
createGroups = app.ui.groupSelect('#create-allowed_groups', {
|
||||||
|
name: 'allowed_groups', values: [], placeholder: 'Type a group name…',
|
||||||
|
});
|
||||||
|
|
||||||
|
// After a successful create, reset the widgets too (form reset ignores them).
|
||||||
|
$('form[action="oauth/client/"]').attr('evalAJAX',
|
||||||
|
'showSecret(data.client_secret, "Client Secret"); tableAJAX(); $form.trigger("reset"); createScopes.set(DEFAULT_SCOPES); createGroups.clear();'
|
||||||
|
);
|
||||||
|
$('form[action="service-account/"]').attr('evalAJAX',
|
||||||
|
'showSecret(data.password, "Password for " + data.cn); svcTableAJAX(); $form.trigger("reset");'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h4><i class="fa-solid fa-plug"></i> Integrations</h4>
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="tab-oauth-btn" data-bs-toggle="tab" data-bs-target="#tab-oauth" type="button" role="tab">
|
||||||
|
<i class="fa-solid fa-key"></i> OAuth Apps
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="tab-ldap-btn" data-bs-toggle="tab" data-bs-target="#tab-ldap" type="button" role="tab">
|
||||||
|
<i class="fa-solid fa-network-wired"></i> LDAP
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="tab-oauth" role="tabpanel">
|
||||||
|
<div class="row" style="display:none">
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<div class="card shadow-sm border-info">
|
||||||
|
<div class="card-header bg-info bg-opacity-10">
|
||||||
|
<i class="fa-solid fa-circle-info"></i>
|
||||||
|
OpenID Connect Endpoints
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-2 text-muted small">
|
||||||
|
Point OIDC/OAuth clients (e.g. Home Assistant) at the discovery URL below.
|
||||||
|
It advertises the authorization, token, and userinfo endpoints automatically.
|
||||||
|
</p>
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-2">Issuer</dt>
|
||||||
|
<dd class="col-sm-10"><code><%= issuer %></code></dd>
|
||||||
|
<dt class="col-sm-2">Discovery URL</dt>
|
||||||
|
<dd class="col-sm-10">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" id="discoveryUrl" class="form-control font-monospace" readonly value="<%= discoveryUrl %>">
|
||||||
|
<a class="btn btn-outline-secondary" href="<%= discoveryUrl %>" target="_blank" title="Open"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('discoveryUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-lg">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="fa-solid fa-plus"></i>
|
||||||
|
Register OAuth Client
|
||||||
|
</div>
|
||||||
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="oauth/client/" method="post" onsubmit="formAJAX(this)">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-control shadow" name="name" placeholder="Home Assistant" validate=":1">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input type="text" class="form-control shadow" name="description" placeholder="Home automation dashboard">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
||||||
|
<textarea class="form-control shadow font-monospace" name="redirect_uris" rows="3"
|
||||||
|
placeholder="https://ha.example.com/auth/external/callback" validate=":1"></textarea>
|
||||||
|
<small class="field-help text-muted d-block">
|
||||||
|
<code>*</code> matches one hostname label and <code>**</code> matches any
|
||||||
|
number of labels, e.g. <code>https://*.example.com/__proxy_auth/callback</code>
|
||||||
|
covers every host theta42/proxy fronts under example.com without registering
|
||||||
|
each one individually.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Scopes</label>
|
||||||
|
<div id="create-scopes"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
|
||||||
|
<div id="create-allowed_groups"></div>
|
||||||
|
<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">
|
||||||
|
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
||||||
|
<input type="number" class="form-control shadow" name="token_lifetime[access_token]" value="3600" min="60">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
||||||
|
<input type="number" class="form-control shadow" name="token_lifetime[refresh_token]" value="2592000" min="3600">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-outline-dark">
|
||||||
|
<i class="fa-solid fa-plus"></i> Register
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8" style="background-color: initial; border: none">
|
||||||
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
|
||||||
|
<div jq-repeat="oauthClientCard" jq-index-key="client_id" id="oauth-card-{{client_id}}" class="card shadow mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>
|
||||||
|
<i class="fa-solid fa-server"></i>
|
||||||
|
{{ name }}
|
||||||
|
</h5>
|
||||||
|
<small class="text-muted font-monospace">{{ client_id }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{ #description }}
|
||||||
|
<p>{{ description }}</p>
|
||||||
|
{{ /description }}
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-3">Client ID</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" id="clientid-{{client_id}}" class="form-control font-monospace" readonly value="{{client_id}}">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('clientid-{{client_id}}', this)" title="Copy Client ID"><i class="fa-solid fa-copy"></i></button>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
<dt class="col-sm-3">Redirect URIs</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
<ul class="list-unstyled mb-0">
|
||||||
|
{{ #redirect_uris }}
|
||||||
|
<li><code>{{ . }}</code></li>
|
||||||
|
{{ /redirect_uris }}
|
||||||
|
</ul>
|
||||||
|
</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>
|
||||||
|
<dd class="col-sm-9">{{ refresh_token_ttl }}</dd>
|
||||||
|
<dt class="col-sm-3">Created by</dt>
|
||||||
|
<dd class="col-sm-9">{{ created_by }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<button type="button"
|
||||||
|
onclick="editClient('{{client_id}}')"
|
||||||
|
class="btn btn-primary btn-sm">
|
||||||
|
<i class="fa-solid fa-pen-to-square"></i> Edit
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
onclick="rotateSecret('{{client_id}}', '{{name}}', this)"
|
||||||
|
class="btn btn-warning btn-sm">
|
||||||
|
<i class="fa-solid fa-arrows-rotate"></i> Rotate Secret
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
onclick="deleteClient('{{client_id}}', '{{name}}', this)"
|
||||||
|
class="btn btn-danger btn-sm float-end">
|
||||||
|
<i class="fa-solid fa-trash"></i> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="tab-ldap" role="tabpanel">
|
||||||
|
<p class="text-muted">
|
||||||
|
Everything a 3rd-party app or host needs to bind this directory, filled in
|
||||||
|
for <b><%= ssoUrl %></b>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card shadow-lg">
|
||||||
|
<div class="card-header shadow">
|
||||||
|
<i class="fa-solid fa-circle-info"></i> Connection details
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small">
|
||||||
|
For a single app's own "LDAP authentication" settings — see
|
||||||
|
<a href="https://theta42.github.io/sso-manager-node/ldap.html#connecting-a-3rd-party-app-or-container" target="_blank">Connecting a 3rd-party app or container</a>
|
||||||
|
for a field-by-field walkthrough (Gitea, generic Docker <code>LDAP_*</code> env vars, …).
|
||||||
|
</p>
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-4">LDAPS URL</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" id="f-ldapsUrl" class="form-control font-monospace" readonly value="<%= ldapsUrl %>">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-ldapsUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Base DN</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" id="f-baseDn" class="form-control font-monospace" readonly value="<%= baseDn %>">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-baseDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">User search base</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" id="f-userBase" class="form-control font-monospace" readonly value="<%= userBase %>">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userBase', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Group search base</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" id="f-groupBase" class="form-control font-monospace" readonly value="<%= groupBase %>">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-groupBase', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">User filter</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" id="f-userFilter" class="form-control font-monospace" readonly value="<%= userFilter %>">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userFilter', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Username attribute</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" id="f-userNameAttribute" class="form-control font-monospace" readonly value="<%= userNameAttribute %>">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userNameAttribute', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Example bind DN</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" id="f-bindDn" class="form-control font-monospace" readonly value="<%= exampleBindDn %>">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-bindDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||||
|
</div>
|
||||||
|
<small class="field-help text-muted d-block">
|
||||||
|
A read-only bind account — create one below under
|
||||||
|
<b>Service Accounts</b> (don't reuse a real person's login or the admin DN).
|
||||||
|
</small>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card shadow-lg">
|
||||||
|
<div class="card-header shadow">
|
||||||
|
<i class="fa-solid fa-terminal"></i> Set up a Linux host (ldap-client)
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small">
|
||||||
|
For full host login, SSH keys, and sudo via LDAP (not just one app) —
|
||||||
|
clone <a href="https://github.com/theta42/ldap-client" target="_blank">theta42/ldap-client</a>
|
||||||
|
and run this on the host. Fill in a service account's password (create
|
||||||
|
one below) and, if you want this host's access/sudo groups
|
||||||
|
auto-registered, an <a href="/">API token</a> from your Profile.
|
||||||
|
</p>
|
||||||
|
<div class="input-group">
|
||||||
|
<textarea id="f-bashSnippet" class="form-control font-monospace" rows="16" readonly style="font-size:.8rem"></textarea>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm mt-2" type="button" onclick="copyField('f-bashSnippet', this)">
|
||||||
|
<i class="fa-solid fa-copy"></i> Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm border-info">
|
||||||
|
<div class="card-header bg-info bg-opacity-10">
|
||||||
|
<i class="fa-solid fa-user-gear"></i> Service Accounts
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Bind-only LDAP identities for apps and hosts — not real people, can't log
|
||||||
|
into this UI, no home directory. theta-env's <code>cn=ldapclient</code>
|
||||||
|
bootstrap account (used by theta42/proxy) shows up here too, since it's
|
||||||
|
the same kind of account.
|
||||||
|
</p>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<form action="service-account/" method="post" onsubmit="formAJAX(this)">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-control shadow" name="cn" placeholder="ldapclient" validate=":1">
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Description <small class="text-muted">(optional)</small></label>
|
||||||
|
<input type="text" class="form-control shadow" name="description" placeholder="Bind account for gitea.example.com">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-outline-dark btn-sm">
|
||||||
|
<i class="fa-solid fa-plus"></i> Create
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead><tr><th>Name</th><th>Description</th><th></th></tr></thead>
|
||||||
|
<tbody jq-repeat="serviceAccountCard">
|
||||||
|
<tr>
|
||||||
|
<td><code>cn={{cn}},<%= userBase %></code></td>
|
||||||
|
<td>{{description}}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-warning" title="Rotate password" onclick="rotateServiceAccountPassword('{{cn}}', this)">
|
||||||
|
<i class="fa-solid fa-key"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" title="Delete" onclick="deleteServiceAccount('{{cn}}', this)">
|
||||||
|
<i class="fa-solid fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
(function(){
|
||||||
|
var lines = [
|
||||||
|
'git clone https://github.com/theta42/ldap-client.git',
|
||||||
|
'cd ldap-client',
|
||||||
|
'cat > ldap.vars << \'EOF\'',
|
||||||
|
'export ldap_host="<%= ldapHost %>"',
|
||||||
|
'export ldap_base_dn="<%= baseDn %>"',
|
||||||
|
'',
|
||||||
|
'# A read-only service account -- create one under Service Accounts',
|
||||||
|
'# above, then fill in its password below.',
|
||||||
|
'export ldap_bind_dn="<%= exampleBindDn %>"',
|
||||||
|
'export ldap_bind_password="CHANGE-ME"',
|
||||||
|
'',
|
||||||
|
'# Optional: auto-register this host\'s access/sudo groups in the SSO',
|
||||||
|
'# Manager. Create a personal access token under Profile > API Tokens',
|
||||||
|
'# and paste it here; leave blank to skip.',
|
||||||
|
'export sso_url="<%= ssoUrl %>"',
|
||||||
|
'export sso_token=""',
|
||||||
|
'',
|
||||||
|
'# Optional: set this if you run ldap-client against more than one site.',
|
||||||
|
'export ldap_location=""',
|
||||||
|
'',
|
||||||
|
'ldap_access_groups=( "${ldap_location}_access" "${ldap_location}_host_$(hostname)_access" )',
|
||||||
|
'EOF',
|
||||||
|
'',
|
||||||
|
'sudo ./index.sh',
|
||||||
|
];
|
||||||
|
document.getElementById('f-bashSnippet').value = lines.join('\n');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
<%- include('top') %>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
app.auth.forceLogin('app_sso_admin');
|
|
||||||
|
|
||||||
function copyField(id, btn){
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if(!el) return;
|
|
||||||
el.select(); el.setSelectionRange(0, 99999); document.execCommand('copy');
|
|
||||||
if(btn){ var $i = $(btn).find('i'), prev = $i.attr('class');
|
|
||||||
$i.attr('class', 'fa-solid fa-check'); setTimeout(function(){ $i.attr('class', prev); }, 1200); }
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h4><i class="fa-solid fa-network-wired"></i> LDAP Info</h4>
|
|
||||||
<p class="text-muted">
|
|
||||||
Everything a 3rd-party app or host needs to bind this directory, filled in
|
|
||||||
for <b><%= ssoUrl %></b>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-header shadow">
|
|
||||||
<i class="fa-solid fa-circle-info"></i> Connection details
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="text-muted small">
|
|
||||||
For a single app's own "LDAP authentication" settings — see
|
|
||||||
<a href="https://theta42.github.io/sso-manager-node/ldap.html#connecting-a-3rd-party-app-or-container" target="_blank">Connecting a 3rd-party app or container</a>
|
|
||||||
for a field-by-field walkthrough (Gitea, generic Docker <code>LDAP_*</code> env vars, …).
|
|
||||||
</p>
|
|
||||||
<dl class="row mb-0">
|
|
||||||
<dt class="col-sm-4">LDAPS URL</dt>
|
|
||||||
<dd class="col-sm-8">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="f-ldapsUrl" class="form-control font-monospace" readonly value="<%= ldapsUrl %>">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-ldapsUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt class="col-sm-4">Base DN</dt>
|
|
||||||
<dd class="col-sm-8">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="f-baseDn" class="form-control font-monospace" readonly value="<%= baseDn %>">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-baseDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt class="col-sm-4">User search base</dt>
|
|
||||||
<dd class="col-sm-8">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="f-userBase" class="form-control font-monospace" readonly value="<%= userBase %>">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userBase', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt class="col-sm-4">Group search base</dt>
|
|
||||||
<dd class="col-sm-8">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="f-groupBase" class="form-control font-monospace" readonly value="<%= groupBase %>">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-groupBase', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt class="col-sm-4">User filter</dt>
|
|
||||||
<dd class="col-sm-8">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="f-userFilter" class="form-control font-monospace" readonly value="<%= userFilter %>">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userFilter', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt class="col-sm-4">Username attribute</dt>
|
|
||||||
<dd class="col-sm-8">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="f-userNameAttribute" class="form-control font-monospace" readonly value="<%= userNameAttribute %>">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userNameAttribute', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt class="col-sm-4">Example bind DN</dt>
|
|
||||||
<dd class="col-sm-8">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="f-bindDn" class="form-control font-monospace" readonly value="<%= exampleBindDn %>">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-bindDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
<small class="field-help text-muted d-block">
|
|
||||||
A read-only bind account — create it as a plain user via
|
|
||||||
<a href="/users">Users</a> (don't put it in <code>app_sso_admin</code>
|
|
||||||
or any other privileged group).
|
|
||||||
</small>
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-header shadow">
|
|
||||||
<i class="fa-solid fa-terminal"></i> Set up a Linux host (ldap-client)
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="text-muted small">
|
|
||||||
For full host login, SSH keys, and sudo via LDAP (not just one app) —
|
|
||||||
clone <a href="https://github.com/theta42/ldap-client" target="_blank">theta42/ldap-client</a>
|
|
||||||
and run this on the host. Fill in the bind account's password and,
|
|
||||||
if you want this host's access/sudo groups auto-registered, an
|
|
||||||
<a href="/">API token</a> from your Profile.
|
|
||||||
</p>
|
|
||||||
<div class="input-group">
|
|
||||||
<textarea id="f-bashSnippet" class="form-control font-monospace" rows="16" readonly style="font-size:.8rem"></textarea>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-outline-secondary btn-sm mt-2" type="button" onclick="copyField('f-bashSnippet', this)">
|
|
||||||
<i class="fa-solid fa-copy"></i> Copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
(function(){
|
|
||||||
var lines = [
|
|
||||||
'git clone https://github.com/theta42/ldap-client.git',
|
|
||||||
'cd ldap-client',
|
|
||||||
'cat > ldap.vars << \'EOF\'',
|
|
||||||
'export ldap_host="<%= ldapHost %>"',
|
|
||||||
'export ldap_base_dn="<%= baseDn %>"',
|
|
||||||
'',
|
|
||||||
'# A read-only service account -- create it via Users (a plain user,',
|
|
||||||
'# not the admin DN), then fill in its password below.',
|
|
||||||
'export ldap_bind_dn="<%= exampleBindDn %>"',
|
|
||||||
'export ldap_bind_password="CHANGE-ME"',
|
|
||||||
'',
|
|
||||||
'# Optional: auto-register this host\'s access/sudo groups in the SSO',
|
|
||||||
'# Manager. Create a personal access token under Profile > API Tokens',
|
|
||||||
'# and paste it here; leave blank to skip.',
|
|
||||||
'export sso_url="<%= ssoUrl %>"',
|
|
||||||
'export sso_token=""',
|
|
||||||
'',
|
|
||||||
'# Optional: set this if you run ldap-client against more than one site.',
|
|
||||||
'export ldap_location=""',
|
|
||||||
'',
|
|
||||||
'ldap_access_groups=( "${ldap_location}_access" "${ldap_location}_host_$(hostname)_access" )',
|
|
||||||
'EOF',
|
|
||||||
'',
|
|
||||||
'sudo ./index.sh',
|
|
||||||
];
|
|
||||||
document.getElementById('f-bashSnippet').value = lines.join('\n');
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<%- include('bottom') %>
|
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
<%- include('top') %>
|
|
||||||
|
|
||||||
<!-- Edit modal -->
|
|
||||||
<div class="modal fade" id="editModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"><i class="fa-solid fa-pen-to-square"></i> Edit OAuth Client</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="card-header actionMessage mb-3" style="display:none"></div>
|
|
||||||
<input type="hidden" id="edit-client-id">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Name</label>
|
|
||||||
<input type="text" id="edit-name" class="form-control shadow">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Description</label>
|
|
||||||
<input type="text" id="edit-description" class="form-control shadow">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
|
||||||
<textarea id="edit-redirect_uris" class="form-control shadow font-monospace" rows="3"></textarea>
|
|
||||||
<small class="field-help text-muted d-block">
|
|
||||||
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Scopes</label>
|
|
||||||
<div id="edit-scopes"></div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
|
|
||||||
<div id="edit-allowed_groups"></div>
|
|
||||||
</div>
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col">
|
|
||||||
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
|
||||||
<input type="number" id="edit-access_ttl" class="form-control shadow" min="60">
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
|
||||||
<input type="number" id="edit-refresh_ttl" class="form-control shadow" min="3600">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="saveEdit(this)"><i class="fa-solid fa-floppy-disk"></i> Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Secret modal -->
|
|
||||||
<div class="modal fade" id="secretModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"><i class="fa-solid fa-key"></i> Client Secret</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this secret now — it will <strong>not</strong> be shown again.</p>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" id="secretValue" class="form-control font-monospace" readonly>
|
|
||||||
<button class="btn btn-outline-secondary" onclick="copySecret()" title="Copy">
|
|
||||||
<i class="fa-solid fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Done</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
app.auth.forceLogin('app_sso_oauth_admin');
|
|
||||||
|
|
||||||
// The scopes this provider actually understands (see routes/oauth.js discovery).
|
|
||||||
var VALID_SCOPES = ['openid', 'profile', 'email', 'groups'];
|
|
||||||
var DEFAULT_SCOPES = ['openid', 'profile', 'email', 'groups'];
|
|
||||||
|
|
||||||
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
|
|
||||||
var editModal = new bootstrap.Modal(document.getElementById('editModal'));
|
|
||||||
|
|
||||||
// Widget handles + a lookup of the latest client data (for the edit modal).
|
|
||||||
var createScopes, createGroups, editScopes, editGroups;
|
|
||||||
var clientsById = {};
|
|
||||||
|
|
||||||
function showSecret(secret){
|
|
||||||
document.getElementById('secretValue').value = secret;
|
|
||||||
secretModal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function copySecret(){
|
|
||||||
copyField('secretValue');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy the value of an input by id; briefly flips the button icon to a check.
|
|
||||||
function copyField(id, btn){
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if(!el) return;
|
|
||||||
el.select();
|
|
||||||
el.setSelectionRange(0, 99999);
|
|
||||||
document.execCommand('copy');
|
|
||||||
if(btn){
|
|
||||||
var $i = $(btn).find('i');
|
|
||||||
var prev = $i.attr('class');
|
|
||||||
$i.attr('class', 'fa-solid fa-check');
|
|
||||||
setTimeout(function(){ $i.attr('class', prev); }, 1200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTTL(seconds){
|
|
||||||
if(seconds < 3600) return seconds + 's';
|
|
||||||
if(seconds < 86400) return (seconds / 3600).toFixed(1) + 'h';
|
|
||||||
return (seconds / 86400).toFixed(1) + 'd';
|
|
||||||
}
|
|
||||||
|
|
||||||
function processClient(client){
|
|
||||||
clientsById[client.client_id] = client; // keep raw data for the edit modal
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tableAJAX(){
|
|
||||||
let data = await app.oauthClient.list();
|
|
||||||
$.scope.oauthClientCard.empty();
|
|
||||||
$.each(data.results, function(_, client){
|
|
||||||
$.scope.oauthClientCard.push(processClient(client));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteClient(client_id, name, btn){
|
|
||||||
const $card = $(btn).closest('.card');
|
|
||||||
$card.addClass('table-warning');
|
|
||||||
const confirmed = await app.util.actionConfirm('Delete OAuth client "' + name + '"?', $card, 'warning');
|
|
||||||
$card.removeClass('table-warning');
|
|
||||||
if (!confirmed) return;
|
|
||||||
app.api.delete('oauth/client/' + client_id, function(error, data){
|
|
||||||
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
|
||||||
$.scope.oauthClientCard.remove('client_id', client_id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rotateSecret(client_id, name, btn){
|
|
||||||
const $card = $(btn).closest('.card');
|
|
||||||
const confirmed = await app.util.actionConfirm('Rotate secret for "' + name + '"? The old secret will stop working immediately.', $card, 'warning');
|
|
||||||
if (!confirmed) return;
|
|
||||||
app.oauthClient.rotateSecret({client_id: client_id}, function(error, data){
|
|
||||||
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
|
||||||
showSecret(data.client_secret);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the edit modal pre-filled from the client's current values.
|
|
||||||
function editClient(client_id){
|
|
||||||
var c = clientsById[client_id];
|
|
||||||
if(!c) return;
|
|
||||||
$('#edit-client-id').val(client_id);
|
|
||||||
$('#edit-name').val(c.name || '');
|
|
||||||
$('#edit-description').val(c.description || '');
|
|
||||||
$('#edit-redirect_uris').val((c.redirect_uris || []).join('\n'));
|
|
||||||
$('#edit-access_ttl').val((c.token_lifetime || {}).access_token || 3600);
|
|
||||||
$('#edit-refresh_ttl').val((c.token_lifetime || {}).refresh_token || 2592000);
|
|
||||||
|
|
||||||
// (Re)build the tag widgets fresh each open so they reflect this client.
|
|
||||||
editScopes = app.ui.tagInput('#edit-scopes', {
|
|
||||||
values: c.scopes || [], options: VALID_SCOPES, freeSolo: false,
|
|
||||||
separator: ' ', placeholder: 'Add a scope…',
|
|
||||||
});
|
|
||||||
editGroups = app.ui.groupSelect('#edit-allowed_groups', {
|
|
||||||
values: c.allowed_groups || [], placeholder: 'Type a group name…',
|
|
||||||
});
|
|
||||||
editModal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveEdit(btn){
|
|
||||||
var $msg = $('#editModal .actionMessage');
|
|
||||||
var payload = {
|
|
||||||
client_id: $('#edit-client-id').val(),
|
|
||||||
name: $('#edit-name').val(),
|
|
||||||
description: $('#edit-description').val(),
|
|
||||||
redirect_uris: $('#edit-redirect_uris').val().split('\n').map(function(s){ return s.trim(); }).filter(Boolean),
|
|
||||||
scopes: editScopes.get(),
|
|
||||||
allowed_groups: editGroups.get(),
|
|
||||||
token_lifetime: {
|
|
||||||
access_token: Number($('#edit-access_ttl').val()) || 3600,
|
|
||||||
refresh_token: Number($('#edit-refresh_ttl').val()) || 2592000,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
app.oauthClient.update(payload, function(error, data){
|
|
||||||
if(error){
|
|
||||||
app.util.actionMessage((data && data.message) || 'Update failed.', $msg.parent(), 'danger');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editModal.hide();
|
|
||||||
tableAJAX();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(function(){
|
|
||||||
tableAJAX();
|
|
||||||
|
|
||||||
// Initialise the create-form tag widgets.
|
|
||||||
createScopes = app.ui.tagInput('#create-scopes', {
|
|
||||||
name: 'scopes', values: DEFAULT_SCOPES, options: VALID_SCOPES,
|
|
||||||
freeSolo: false, separator: ' ', placeholder: 'Add a scope…',
|
|
||||||
});
|
|
||||||
createGroups = app.ui.groupSelect('#create-allowed_groups', {
|
|
||||||
name: 'allowed_groups', values: [], placeholder: 'Type a group name…',
|
|
||||||
});
|
|
||||||
|
|
||||||
// After a successful create, reset the widgets too (form reset ignores them).
|
|
||||||
$('form[action="oauth/client/"]').attr('evalAJAX',
|
|
||||||
'showSecret(data.client_secret); tableAJAX(); $form.trigger("reset"); createScopes.set(DEFAULT_SCOPES); createGroups.clear();'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
|
||||||
<div class="row" style="display:none">
|
|
||||||
<div class="col-12 mb-3">
|
|
||||||
<div class="card shadow-sm border-info">
|
|
||||||
<div class="card-header bg-info bg-opacity-10">
|
|
||||||
<i class="fa-solid fa-circle-info"></i>
|
|
||||||
OpenID Connect Endpoints
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="mb-2 text-muted small">
|
|
||||||
Point OIDC/OAuth clients (e.g. Home Assistant) at the discovery URL below.
|
|
||||||
It advertises the authorization, token, and userinfo endpoints automatically.
|
|
||||||
</p>
|
|
||||||
<dl class="row mb-0">
|
|
||||||
<dt class="col-sm-2">Issuer</dt>
|
|
||||||
<dd class="col-sm-10"><code><%= issuer %></code></dd>
|
|
||||||
<dt class="col-sm-2">Discovery URL</dt>
|
|
||||||
<dd class="col-sm-10">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="discoveryUrl" class="form-control font-monospace" readonly value="<%= discoveryUrl %>">
|
|
||||||
<a class="btn btn-outline-secondary" href="<%= discoveryUrl %>" target="_blank" title="Open"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('discoveryUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="fa-solid fa-plus"></i>
|
|
||||||
Register OAuth Client
|
|
||||||
</div>
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form action="oauth/client/" method="post" onsubmit="formAJAX(this)">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Name</label>
|
|
||||||
<input type="text" class="form-control shadow" name="name" placeholder="Home Assistant" validate=":1">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Description</label>
|
|
||||||
<input type="text" class="form-control shadow" name="description" placeholder="Home automation dashboard">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
|
||||||
<textarea class="form-control shadow font-monospace" name="redirect_uris" rows="3"
|
|
||||||
placeholder="https://ha.example.com/auth/external/callback" validate=":1"></textarea>
|
|
||||||
<small class="field-help text-muted d-block">
|
|
||||||
<code>*</code> matches one hostname label and <code>**</code> matches any
|
|
||||||
number of labels, e.g. <code>https://*.example.com/__proxy_auth/callback</code>
|
|
||||||
covers every host theta42/proxy fronts under example.com without registering
|
|
||||||
each one individually.
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Scopes</label>
|
|
||||||
<div id="create-scopes"></div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
|
|
||||||
<div id="create-allowed_groups"></div>
|
|
||||||
<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">
|
|
||||||
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
|
||||||
<input type="number" class="form-control shadow" name="token_lifetime[access_token]" value="3600" min="60">
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
|
||||||
<input type="number" class="form-control shadow" name="token_lifetime[refresh_token]" value="2592000" min="3600">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-outline-dark">
|
|
||||||
<i class="fa-solid fa-plus"></i> Register
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-8" style="background-color: initial; border: none">
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
|
|
||||||
<div jq-repeat="oauthClientCard" jq-index-key="client_id" id="oauth-card-{{client_id}}" class="card shadow mb-3">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5>
|
|
||||||
<i class="fa-solid fa-server"></i>
|
|
||||||
{{ name }}
|
|
||||||
</h5>
|
|
||||||
<small class="text-muted font-monospace">{{ client_id }}</small>
|
|
||||||
</div>
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
{{ #description }}
|
|
||||||
<p>{{ description }}</p>
|
|
||||||
{{ /description }}
|
|
||||||
<dl class="row mb-0">
|
|
||||||
<dt class="col-sm-3">Client ID</dt>
|
|
||||||
<dd class="col-sm-9">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="clientid-{{client_id}}" class="form-control font-monospace" readonly value="{{client_id}}">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('clientid-{{client_id}}', this)" title="Copy Client ID"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
<dt class="col-sm-3">Redirect URIs</dt>
|
|
||||||
<dd class="col-sm-9">
|
|
||||||
<ul class="list-unstyled mb-0">
|
|
||||||
{{ #redirect_uris }}
|
|
||||||
<li><code>{{ . }}</code></li>
|
|
||||||
{{ /redirect_uris }}
|
|
||||||
</ul>
|
|
||||||
</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>
|
|
||||||
<dd class="col-sm-9">{{ refresh_token_ttl }}</dd>
|
|
||||||
<dt class="col-sm-3">Created by</dt>
|
|
||||||
<dd class="col-sm-9">{{ created_by }}</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<button type="button"
|
|
||||||
onclick="editClient('{{client_id}}')"
|
|
||||||
class="btn btn-primary btn-sm">
|
|
||||||
<i class="fa-solid fa-pen-to-square"></i> Edit
|
|
||||||
</button>
|
|
||||||
<button type="button"
|
|
||||||
onclick="rotateSecret('{{client_id}}', '{{name}}', this)"
|
|
||||||
class="btn btn-warning btn-sm">
|
|
||||||
<i class="fa-solid fa-arrows-rotate"></i> Rotate Secret
|
|
||||||
</button>
|
|
||||||
<button type="button"
|
|
||||||
onclick="deleteClient('{{client_id}}', '{{name}}', this)"
|
|
||||||
class="btn btn-danger btn-sm float-end">
|
|
||||||
<i class="fa-solid fa-trash"></i> Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<%- include('bottom') %>
|
|
||||||
+4
-10
@@ -50,16 +50,10 @@
|
|||||||
Groups
|
Groups
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item group-required group-required-app_sso_admin">
|
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_oauth_admin">
|
||||||
<a class="nav-link" href="/oauth-clients">
|
<a class="nav-link" href="/integrations">
|
||||||
<i class="fa-solid fa-key"></i>
|
<i class="fa-solid fa-plug"></i>
|
||||||
OAuth Apps
|
Integrations
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item group-required group-required-app_sso_admin">
|
|
||||||
<a class="nav-link" href="/ldap-info">
|
|
||||||
<i class="fa-solid fa-network-wired"></i>
|
|
||||||
LDAP Info
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_invite">
|
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_invite">
|
||||||
|
|||||||
Reference in New Issue
Block a user