oath fixes
This commit is contained in:
@@ -73,7 +73,8 @@ router.get('/login', async 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`});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
const authRouter = express.Router();
|
||||
@@ -355,6 +393,7 @@ function discovery(req, res) {
|
||||
authorization_endpoint: `${base}/oauth/authorize`,
|
||||
token_endpoint: `${base}/oauth/token`,
|
||||
userinfo_endpoint: `${base}/oauth/userinfo`,
|
||||
end_session_endpoint: `${base}/oauth/logout`,
|
||||
scopes_supported: ['openid', 'profile', 'email'],
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
|
||||
@@ -57,6 +57,33 @@ describe('OIDC Discovery', () => {
|
||||
expect(res.body.grant_types_supported).toContain('refresh_token');
|
||||
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)', () => {
|
||||
|
||||
@@ -5,17 +5,24 @@
|
||||
var oauthParams = <%- JSON.stringify(params) %>;
|
||||
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(){
|
||||
|
||||
// Override the global hold-ready redirect so we control the login destination
|
||||
app.auth.isLoggedIn(function(error, isLoggedIn){
|
||||
if(error || !isLoggedIn){
|
||||
app.auth.logOut(function(){});
|
||||
var returnUrl = '/oauth/authorize?' + $.param(oauthParams);
|
||||
location.replace('/login?redirect=' + encodeURIComponent(returnUrl));
|
||||
// Require an authenticated session before showing the consent screen.
|
||||
app.auth.isLoggedIn().then(function(user){
|
||||
if(!user){
|
||||
goToLogin();
|
||||
return;
|
||||
}
|
||||
$('div.row').fadeIn('slow');
|
||||
}).catch(function(){
|
||||
goToLogin();
|
||||
});
|
||||
|
||||
$('#btn-deny').on('click', function(){
|
||||
|
||||
@@ -35,9 +35,22 @@
|
||||
}
|
||||
|
||||
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.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){
|
||||
@@ -93,6 +106,32 @@
|
||||
|
||||
</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">
|
||||
@@ -153,6 +192,13 @@
|
||||
<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">
|
||||
|
||||
@@ -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…</h5>
|
||||
<p class="text-muted mb-0">You are being redirected.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('bottom') %>
|
||||
Reference in New Issue
Block a user