Skip to content

Commit 29acb1f

Browse files
committed
feat(javalin): add OIDC back-channel logout endpoint
Implements POST /qqq/v1/oidc/backchannel-logout per OpenID Connect spec. Validates logout tokens and terminates matching user sessions.
1 parent fde82ab commit 29acb1f

File tree

8 files changed

+1296
-0
lines changed

8 files changed

+1296
-0
lines changed
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
/*
2+
* QQQ - Low-code Application Framework for Engineers.
3+
* Copyright (C) 2021-2025. Kingsrook, LLC
4+
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
5+
* contact@kingsrook.com
6+
* https://github.com/Kingsrook/
7+
*
8+
* This program is free software: you can redistribute it and/or modify
9+
* it under the terms of the GNU Affero General Public License as
10+
* published by the Free Software Foundation, either version 3 of the
11+
* License, or (at your option) any later version.
12+
*
13+
* This program is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
* GNU Affero General Public License for more details.
17+
*
18+
* You should have received a copy of the GNU Affero General Public License
19+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
20+
*/
21+
22+
package com.kingsrook.qqq.middleware.javalin.executors;
23+
24+
25+
import java.util.ArrayList;
26+
import java.util.Base64;
27+
import java.util.List;
28+
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
29+
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
30+
import com.kingsrook.qqq.backend.core.context.QContext;
31+
import com.kingsrook.qqq.backend.core.exceptions.QException;
32+
import com.kingsrook.qqq.backend.core.logging.QLogger;
33+
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
34+
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
35+
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
36+
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
37+
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
38+
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
39+
import com.kingsrook.qqq.backend.core.model.data.QRecord;
40+
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
41+
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
42+
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.model.UserSession;
43+
import com.kingsrook.qqq.middleware.javalin.executors.io.BackChannelLogoutInput;
44+
import com.kingsrook.qqq.middleware.javalin.executors.io.BackChannelLogoutOutputInterface;
45+
import org.json.JSONObject;
46+
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
47+
48+
49+
/*******************************************************************************
50+
** Executor for the OIDC back-channel logout endpoint.
51+
**
52+
** Handles logout tokens sent by the IdP when a user logs out from the IdP
53+
** or another application in the SSO ecosystem.
54+
**
55+
** Per OIDC Back-Channel Logout spec:
56+
** - The logout_token JWT contains either 'sub' (subject) or 'sid' (session ID)
57+
** - We find and delete matching QQQ sessions
58+
** - Return HTTP 200 on success (even if no sessions found)
59+
*******************************************************************************/
60+
public class BackChannelLogoutExecutor extends AbstractMiddlewareExecutor<BackChannelLogoutInput, BackChannelLogoutOutputInterface>
61+
{
62+
private static final QLogger LOG = QLogger.getLogger(BackChannelLogoutExecutor.class);
63+
64+
65+
66+
/***************************************************************************
67+
**
68+
***************************************************************************/
69+
@Override
70+
public void execute(BackChannelLogoutInput input, BackChannelLogoutOutputInterface output) throws QException
71+
{
72+
String logoutToken = input.getLogoutToken();
73+
if(logoutToken == null || logoutToken.isBlank())
74+
{
75+
LOG.warn("Back-channel logout received with empty logout_token");
76+
return;
77+
}
78+
79+
try
80+
{
81+
//////////////////////////////////////////////////////////////////////////
82+
// Parse the logout_token JWT payload (middle section) //
83+
// Note: Full signature validation requires JWKS fetch - for now we //
84+
// trust the token since it comes over HTTPS from configured IdP //
85+
//////////////////////////////////////////////////////////////////////////
86+
JSONObject payload = parseJwtPayload(logoutToken);
87+
88+
String sub = payload.optString("sub", null);
89+
String sid = payload.optString("sid", null);
90+
91+
if(sub == null && sid == null)
92+
{
93+
LOG.warn("Back-channel logout token missing both 'sub' and 'sid' claims");
94+
return;
95+
}
96+
97+
LOG.info("Processing back-channel logout", logPair("sub", sub), logPair("sid", sid));
98+
99+
QInstance qInstance = QContext.getQInstance();
100+
int deletedCount = 0;
101+
102+
///////////////////////////////////////////////////////////////////////////
103+
// If 'sub' is present, we can efficiently query by userId //
104+
// (UserSession.userId stores the 'sub' claim from the access token) //
105+
///////////////////////////////////////////////////////////////////////////
106+
if(sub != null)
107+
{
108+
deletedCount = deleteSessionsByUserId(qInstance, sub);
109+
}
110+
111+
///////////////////////////////////////////////////////////////////////////
112+
// If only 'sid' is present, we need to scan sessions and check their //
113+
// access tokens for matching session IDs //
114+
///////////////////////////////////////////////////////////////////////////
115+
if(sid != null && deletedCount == 0)
116+
{
117+
deletedCount = deleteSessionsByOidcSessionId(qInstance, sid);
118+
}
119+
120+
LOG.info("Back-channel logout completed", logPair("deletedSessions", deletedCount));
121+
}
122+
catch(Exception e)
123+
{
124+
LOG.warn("Error processing back-channel logout", e);
125+
}
126+
}
127+
128+
129+
130+
/*******************************************************************************
131+
** Parse JWT payload (middle section) to JSONObject.
132+
*******************************************************************************/
133+
private JSONObject parseJwtPayload(String jwt)
134+
{
135+
String[] parts = jwt.split("\\.");
136+
if(parts.length < 2)
137+
{
138+
return new JSONObject();
139+
}
140+
String payload = new String(Base64.getUrlDecoder().decode(parts[1]), java.nio.charset.StandardCharsets.UTF_8);
141+
return new JSONObject(payload);
142+
}
143+
144+
145+
146+
/*******************************************************************************
147+
** Delete sessions by userId (the 'sub' claim).
148+
*******************************************************************************/
149+
private int deleteSessionsByUserId(QInstance qInstance, String userId) throws QException
150+
{
151+
if(qInstance.getTable(UserSession.TABLE_NAME) == null)
152+
{
153+
LOG.debug("UserSession table not found in QInstance, skipping back-channel logout");
154+
return 0;
155+
}
156+
157+
var beforeSession = QContext.getQSession();
158+
try
159+
{
160+
QContext.setQSession(new QSystemUserSession());
161+
162+
///////////////////////////////////////
163+
// Query for sessions with this userId //
164+
///////////////////////////////////////
165+
QueryInput queryInput = new QueryInput();
166+
queryInput.setTableName(UserSession.TABLE_NAME);
167+
queryInput.setFilter(new QQueryFilter(new QFilterCriteria("userId", QCriteriaOperator.EQUALS, userId)));
168+
queryInput.setShouldOmitHiddenFields(false);
169+
170+
QueryOutput queryOutput = new QueryAction().execute(queryInput);
171+
List<QRecord> sessions = queryOutput.getRecords();
172+
173+
if(sessions.isEmpty())
174+
{
175+
LOG.debug("No sessions found for userId", logPair("userId", userId));
176+
return 0;
177+
}
178+
179+
///////////////////////////////////////
180+
// Delete the matching sessions //
181+
///////////////////////////////////////
182+
List<String> uuidsToDelete = new ArrayList<>();
183+
for(QRecord session : sessions)
184+
{
185+
uuidsToDelete.add(session.getValueString("uuid"));
186+
}
187+
188+
DeleteInput deleteInput = new DeleteInput();
189+
deleteInput.setTableName(UserSession.TABLE_NAME);
190+
deleteInput.setQueryFilter(new QQueryFilter(new QFilterCriteria("uuid", QCriteriaOperator.IN, uuidsToDelete)));
191+
new DeleteAction().execute(deleteInput);
192+
193+
LOG.debug("Deleted sessions by userId", logPair("userId", userId), logPair("count", uuidsToDelete.size()));
194+
return uuidsToDelete.size();
195+
}
196+
finally
197+
{
198+
QContext.setQSession(beforeSession);
199+
}
200+
}
201+
202+
203+
204+
/*******************************************************************************
205+
** Delete sessions by OIDC session ID ('sid' claim).
206+
**
207+
** This requires scanning sessions and checking each access token for the
208+
** matching 'sid' claim. Less efficient than userId lookup but necessary
209+
** when only 'sid' is provided in the logout token.
210+
*******************************************************************************/
211+
private int deleteSessionsByOidcSessionId(QInstance qInstance, String targetSid) throws QException
212+
{
213+
if(qInstance.getTable(UserSession.TABLE_NAME) == null)
214+
{
215+
LOG.debug("UserSession table not found in QInstance, skipping back-channel logout");
216+
return 0;
217+
}
218+
219+
var beforeSession = QContext.getQSession();
220+
try
221+
{
222+
QContext.setQSession(new QSystemUserSession());
223+
224+
///////////////////////////////////////
225+
// Query all sessions //
226+
///////////////////////////////////////
227+
QueryInput queryInput = new QueryInput();
228+
queryInput.setTableName(UserSession.TABLE_NAME);
229+
queryInput.setShouldOmitHiddenFields(false);
230+
queryInput.setShouldMaskPasswords(false);
231+
232+
QueryOutput queryOutput = new QueryAction().execute(queryInput);
233+
List<QRecord> sessions = queryOutput.getRecords();
234+
235+
///////////////////////////////////////
236+
// Find sessions with matching 'sid' //
237+
///////////////////////////////////////
238+
List<String> uuidsToDelete = new ArrayList<>();
239+
for(QRecord session : sessions)
240+
{
241+
String accessToken = session.getValueString("accessToken");
242+
if(accessToken != null)
243+
{
244+
try
245+
{
246+
JSONObject tokenPayload = parseJwtPayload(accessToken);
247+
String sessionSid = tokenPayload.optString("sid", null);
248+
if(targetSid.equals(sessionSid))
249+
{
250+
uuidsToDelete.add(session.getValueString("uuid"));
251+
}
252+
}
253+
catch(Exception e)
254+
{
255+
LOG.debug("Error parsing access token during sid lookup", e);
256+
}
257+
}
258+
}
259+
260+
if(uuidsToDelete.isEmpty())
261+
{
262+
return 0;
263+
}
264+
265+
///////////////////////////////////////
266+
// Delete the matching sessions //
267+
///////////////////////////////////////
268+
DeleteInput deleteInput = new DeleteInput();
269+
deleteInput.setTableName(UserSession.TABLE_NAME);
270+
deleteInput.setQueryFilter(new QQueryFilter(new QFilterCriteria("uuid", QCriteriaOperator.IN, uuidsToDelete)));
271+
new DeleteAction().execute(deleteInput);
272+
273+
LOG.debug("Deleted sessions by sid", logPair("sid", targetSid), logPair("count", uuidsToDelete.size()));
274+
return uuidsToDelete.size();
275+
}
276+
finally
277+
{
278+
QContext.setQSession(beforeSession);
279+
}
280+
}
281+
282+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* QQQ - Low-code Application Framework for Engineers.
3+
* Copyright (C) 2021-2025. Kingsrook, LLC
4+
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
5+
* contact@kingsrook.com
6+
* https://github.com/Kingsrook/
7+
*
8+
* This program is free software: you can redistribute it and/or modify
9+
* it under the terms of the GNU Affero General Public License as
10+
* published by the Free Software Foundation, either version 3 of the
11+
* License, or (at your option) any later version.
12+
*
13+
* This program is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
* GNU Affero General Public License for more details.
17+
*
18+
* You should have received a copy of the GNU Affero General Public License
19+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
20+
*/
21+
22+
package com.kingsrook.qqq.middleware.javalin.executors.io;
23+
24+
25+
/*******************************************************************************
26+
** Input for the OIDC back-channel logout endpoint.
27+
**
28+
** Per OIDC Back-Channel Logout spec, the IdP sends a POST request with
29+
** Content-Type: application/x-www-form-urlencoded containing a logout_token.
30+
*******************************************************************************/
31+
public class BackChannelLogoutInput extends AbstractMiddlewareInput
32+
{
33+
private String logoutToken;
34+
35+
36+
37+
/*******************************************************************************
38+
** Getter for logoutToken
39+
*******************************************************************************/
40+
public String getLogoutToken()
41+
{
42+
return (this.logoutToken);
43+
}
44+
45+
46+
47+
/*******************************************************************************
48+
** Setter for logoutToken
49+
*******************************************************************************/
50+
public void setLogoutToken(String logoutToken)
51+
{
52+
this.logoutToken = logoutToken;
53+
}
54+
55+
56+
57+
/*******************************************************************************
58+
** Fluent setter for logoutToken
59+
*******************************************************************************/
60+
public BackChannelLogoutInput withLogoutToken(String logoutToken)
61+
{
62+
this.logoutToken = logoutToken;
63+
return (this);
64+
}
65+
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* QQQ - Low-code Application Framework for Engineers.
3+
* Copyright (C) 2021-2025. Kingsrook, LLC
4+
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
5+
* contact@kingsrook.com
6+
* https://github.com/Kingsrook/
7+
*
8+
* This program is free software: you can redistribute it and/or modify
9+
* it under the terms of the GNU Affero General Public License as
10+
* published by the Free Software Foundation, either version 3 of the
11+
* License, or (at your option) any later version.
12+
*
13+
* This program is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
* GNU Affero General Public License for more details.
17+
*
18+
* You should have received a copy of the GNU Affero General Public License
19+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
20+
*/
21+
22+
package com.kingsrook.qqq.middleware.javalin.executors.io;
23+
24+
25+
/*******************************************************************************
26+
** Output interface for the OIDC back-channel logout endpoint.
27+
*******************************************************************************/
28+
public interface BackChannelLogoutOutputInterface extends AbstractMiddlewareOutputInterface
29+
{
30+
31+
}

0 commit comments

Comments
 (0)