Files
sso-manager-node/nodejs/views/oauth_clients.ejs
T
wmantly 4c6b1e38b1 Support wildcard redirect_uri patterns for OAuth clients
theta42/proxy fronts an arbitrary number of hosts behind SSO, each with its
own callback URL (https://<host>/__proxy_auth/callback) — proxy's own code
comment already assumed "a wildcard redirect URI covers all", but no
wildcard matching existed here, so every proxied host's callback had to be
registered on the shared OAuth client individually or /oauth/authorize
would reject it with InvalidRedirectURI.

Add `*` (one hostname label) / `**` (any number of labels) wildcard support
to redirect_uri matching, e.g. `https://**.example.com/__proxy_auth/callback`
now covers every host proxy fronts under example.com. Exact matches still
work exactly as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 00:42:32 -04:00

384 lines
15 KiB
Plaintext

<%- 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') %>