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) {
|
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) ---
|
// --- 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'],
|
||||||
|
|||||||
@@ -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)', () => {
|
||||||
|
|||||||
@@ -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(){
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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