From 93df047a21a4476ff9e0b710e76849a6b51d3765 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Thu, 2 Jul 2026 16:22:16 -0400 Subject: [PATCH] oath fixes --- nodejs/routes/index.js | 3 +- nodejs/routes/oauth.js | 39 ++++++++++++++++++++++++++ nodejs/tests/oauth.test.js | 27 ++++++++++++++++++ nodejs/views/oauth_authorize.ejs | 19 +++++++++---- nodejs/views/oauth_clients.ejs | 48 +++++++++++++++++++++++++++++++- nodejs/views/oauth_logout.ejs | 24 ++++++++++++++++ 6 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 nodejs/views/oauth_logout.ejs diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index be25150..25ea2bc 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -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`}); }); diff --git a/nodejs/routes/oauth.js b/nodejs/routes/oauth.js index d4888cc..2a1d674 100644 --- a/nodejs/routes/oauth.js +++ b/nodejs/routes/oauth.js @@ -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'], diff --git a/nodejs/tests/oauth.test.js b/nodejs/tests/oauth.test.js index 5f579ca..2115400 100644 --- a/nodejs/tests/oauth.test.js +++ b/nodejs/tests/oauth.test.js @@ -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)', () => { diff --git a/nodejs/views/oauth_authorize.ejs b/nodejs/views/oauth_authorize.ejs index 38bb30b..09dcc1a 100644 --- a/nodejs/views/oauth_authorize.ejs +++ b/nodejs/views/oauth_authorize.ejs @@ -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(){ diff --git a/nodejs/views/oauth_clients.ejs b/nodejs/views/oauth_clients.ejs index 50146b5..b266fff 100644 --- a/nodejs/views/oauth_clients.ejs +++ b/nodejs/views/oauth_clients.ejs @@ -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 @@