oath fixes

This commit is contained in:
2026-07-02 16:22:16 -04:00
parent 46e0be2271
commit 93df047a21
6 changed files with 152 additions and 8 deletions
+2 -1
View File
@@ -73,7 +73,8 @@ router.get('/login', async function(req, res, next) {
}); });
router.get('/oauth-clients', function(req, res, next) { router.get('/oauth-clients', function(req, res, next) {
res.render('oauth_clients', {...values}); const issuer = ((conf.oauth && conf.oauth.issuer) || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
res.render('oauth_clients', {...values, issuer, discoveryUrl: `${issuer}/.well-known/openid-configuration`});
}); });
+39
View File
@@ -300,6 +300,44 @@ router.get('/userinfo', async function(req, res, next) {
} }
}); });
// RP-initiated logout — clears the SSO browser session, then returns the user
// to the requesting app's post_logout_redirect_uri (if it belongs to a
// registered client, to prevent this being used as an open redirect).
router.get('/logout', async function(req, res, next) {
try {
const { post_logout_redirect_uri, state } = req.query;
let target = '/';
if (post_logout_redirect_uri) {
let requested;
try {
requested = new URL(post_logout_redirect_uri);
} catch(_) {
return next(makeError('InvalidRequest', 'post_logout_redirect_uri is not a valid URL.', 400));
}
const clients = await OAuthClient.listDetail();
const allowed = clients.some(client =>
(client.redirect_uris || []).some(uri => {
try { return new URL(uri).origin === requested.origin; }
catch(_) { return false; }
})
);
if (!allowed) {
return next(makeError('InvalidRedirectURI', 'post_logout_redirect_uri origin is not registered for any client.', 400));
}
if (state) requested.searchParams.set('state', state);
target = requested.toString();
}
res.render('oauth_logout', { ...pageLocals, target });
} catch(error) {
next(error);
}
});
// --- authenticated API router (mounted at /api/oauth with auth middleware) --- // --- authenticated API router (mounted at /api/oauth with auth middleware) ---
const authRouter = express.Router(); const authRouter = express.Router();
@@ -355,6 +393,7 @@ function discovery(req, res) {
authorization_endpoint: `${base}/oauth/authorize`, authorization_endpoint: `${base}/oauth/authorize`,
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`,
scopes_supported: ['openid', 'profile', 'email'], scopes_supported: ['openid', 'profile', 'email'],
response_types_supported: ['code'], response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'], grant_types_supported: ['authorization_code', 'refresh_token'],
+27
View File
@@ -57,6 +57,33 @@ describe('OIDC Discovery', () => {
expect(res.body.grant_types_supported).toContain('refresh_token'); expect(res.body.grant_types_supported).toContain('refresh_token');
expect(res.body.code_challenge_methods_supported).toContain('S256'); expect(res.body.code_challenge_methods_supported).toContain('S256');
}); });
test('advertises end_session_endpoint', async () => {
const res = await request(app).get('/.well-known/openid-configuration');
expect(res.body).toHaveProperty('end_session_endpoint');
});
});
describe('OAuth — GET /oauth/logout (RP-initiated logout)', () => {
test('renders logout page with no redirect', async () => {
const res = await request(app).get('/oauth/logout');
expect(res.status).toBe(200);
});
test('accepts a post_logout_redirect_uri on a registered client origin', async () => {
const res = await request(app)
.get('/oauth/logout')
.query({ post_logout_redirect_uri: 'https://test.example.com/' });
expect(res.status).toBe(200);
expect(res.text).toContain('https://test.example.com/');
});
test('rejects a post_logout_redirect_uri on an unregistered origin', async () => {
const res = await request(app)
.get('/oauth/logout')
.query({ post_logout_redirect_uri: 'https://evil.example.com/' });
expect(res.status).toBeGreaterThanOrEqual(400);
});
}); });
describe('OAuth — GET /oauth/authorize (consent page validation)', () => { describe('OAuth — GET /oauth/authorize (consent page validation)', () => {
+13 -6
View File
@@ -5,17 +5,24 @@
var oauthParams = <%- JSON.stringify(params) %>; var oauthParams = <%- JSON.stringify(params) %>;
var clientInfo = <%- JSON.stringify(oauthClient) %>; var clientInfo = <%- JSON.stringify(oauthClient) %>;
// Send the user to log in, then return to this exact authorize request.
// logInRedirect() strips the leading "/login" after auth, so the return
// target must be encoded as a path under /login (not a ?redirect= param).
function goToLogin(){
location.replace('/login/oauth/authorize?' + $.param(oauthParams));
}
$(document).ready(function(){ $(document).ready(function(){
// Override the global hold-ready redirect so we control the login destination // Require an authenticated session before showing the consent screen.
app.auth.isLoggedIn(function(error, isLoggedIn){ app.auth.isLoggedIn().then(function(user){
if(error || !isLoggedIn){ if(!user){
app.auth.logOut(function(){}); goToLogin();
var returnUrl = '/oauth/authorize?' + $.param(oauthParams);
location.replace('/login?redirect=' + encodeURIComponent(returnUrl));
return; return;
} }
$('div.row').fadeIn('slow'); $('div.row').fadeIn('slow');
}).catch(function(){
goToLogin();
}); });
$('#btn-deny').on('click', function(){ $('#btn-deny').on('click', function(){
+47 -1
View File
@@ -35,9 +35,22 @@
} }
function copySecret(){ function copySecret(){
var el = document.getElementById('secretValue'); 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.select();
el.setSelectionRange(0, 99999);
document.execCommand('copy'); 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){ function fmtTTL(seconds){
@@ -93,6 +106,32 @@
</script> </script>
<div class="row" style="display:none"> <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="col-md-4">
<div class="card shadow-lg"> <div class="card shadow-lg">
<div class="card-header"> <div class="card-header">
@@ -153,6 +192,13 @@
<p>{{ description }}</p> <p>{{ description }}</p>
{{ /description }} {{ /description }}
<dl class="row mb-0"> <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> <dt class="col-sm-3">Redirect URIs</dt>
<dd class="col-sm-9"> <dd class="col-sm-9">
<ul class="list-unstyled mb-0"> <ul class="list-unstyled mb-0">
+24
View File
@@ -0,0 +1,24 @@
<%- include('top') %>
<script type="text/javascript">
// Server-provided, validated redirect target ('/' when none supplied).
var logoutTarget = <%- JSON.stringify(target) %>;
$(document).ready(function(){
// Clear the SSO browser session, then return to the requesting app.
try { localStorage.removeItem('APIToken'); } catch(e){}
window.location.replace(logoutTarget);
});
</script>
<div class="row" style="display:none">
<div class="col-md-4 offset-md-4">
<div class="card shadow-lg">
<div class="card-body text-center">
<h5><i class="fa-solid fa-right-from-bracket"></i> Signing out&hellip;</h5>
<p class="text-muted mb-0">You are being redirected.</p>
</div>
</div>
</div>
</div>
<%- include('bottom') %>