Skip to content

Commit

Permalink
Make the logout button actually log out (#24)
Browse files Browse the repository at this point in the history
* Make the logout button actually log out

- Nexus has automatic session creation turned off and solely relies on the login dialog POSTing to SessionServlet for session creation
- We never have a login dialog, so this does not happen, thus we turn on automatic session creation when our token factory is used
- Oauth2 proxy has a "skip_jwt_bearer_tokens" flag that makes it accept, validate and process a sent jwt token instead of requesting its own
- It still adds all necessary headers, but also forwards the Auth header which made our token factoy refuse the operation before
- By doing this, the logout button actually works, destroying the user session and triggering a logout event we can listen for
- When this event triggers, we perform backchannel logout via oauth proxy, which can then backchannel logout from the IDP
- This logout requires page reload to not leave Nexus in an invalid state, however the logout call refuses to follow any kind of redirect so a kinda hacky solution in the frontend is necessary
- We use javascript document observers to heuristically guess when logout occured and thus a page refresh should be triggered

* better error handling + configurable logout url

* adjust documentation to hint towards the new capability

---------

Co-authored-by: Marco Herglotz <marco.herglotz@cas.de>
  • Loading branch information
herglotzmarco and Marco Herglotz authored Jan 8, 2025
1 parent 8774847 commit 25f9b12
Show file tree
Hide file tree
Showing 12 changed files with 488 additions and 81 deletions.
5 changes: 4 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ It is important to highlight that this plugin is provided on an 'as-is' basis, w
* automatic expiry of API tokens
* there is a configurable task that lets API tokens expire, so another login by the user is necessary to renew it
* as long as the user keeps showing up regularly, their token will not expire
* backchannel logout in IDP via oauth2 proxy (if supported) when the logout is performed in Nexus
* make sure to enable the OAuth2 Proxy: Logout capability in Nexus to make this work

**Note**: After authenticating with this realm, the logout button is non-operative, which is a common limitation with header-based authentication methods. To force a logout, you need to logout from your identity provider and/or delete the OAuth2 Proxy cookie if you must logout for some reason.
**Note**: If the OAuth2 Proxy: Logout capability is not enabled, the logout button is non-operative, which is a common limitation with header-based authentication methods. To force a logout, you need to logout from your identity provider and/or delete the OAuth2 Proxy cookie if you must logout for some reason.

## Supported Nexus version

Expand Down Expand Up @@ -158,6 +160,7 @@ code_challenge_method = "S256" # PKCE, if your idp supports that
client_id = "get the client id from your identity provider"
client_secret = "get the secret from your identity provider"
cookie_secret = "generate an individual cookie secret"
backend_logout_url = "https://idm.example.com/consult/your/idp-documentation/for/logout-url?id_token_hint={id_token}"
# we don't need to wait for people to press the button, just redirect
skip_provider_button = true
Expand Down
57 changes: 57 additions & 0 deletions src/frontend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,61 @@ window.plugins.push({
requiresUser: true
}
}]
});

const toolbarXPath = '//div[contains(@class, "x-toolbar") and contains(@role, "group")]'
const loginButtonXPath = '//a[contains(@id, "signin")]'
const logoutButtonXPath = '//a[contains(@id, "signout")]'

function findFirstElementByXPath(xPath, searchRoot) {
return document.evaluate(xPath, searchRoot, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
}

function waitForElement(xPath, searchRoot, elementNameForLogging) {
elementNameForLogging = (typeof elementNameForLogging === 'undefined') ? xPath : elementNameForLogging;
return new Promise(resolve => {
let initElement = findFirstElementByXPath(xPath, searchRoot);
if(initElement && initElement.checkVisibility()) {
return resolve(initElement);
}

console.log(elementNameForLogging + " not yet visible.");
const observer = new MutationObserver(mutations => {
let obsElement = findFirstElementByXPath(xPath, searchRoot);
if(obsElement && obsElement.checkVisibility()) {
console.log("Observer found " + elementNameForLogging + ". Unregistering observer and resolving promise");
observer.disconnect();
resolve(obsElement)
}
});
observer.observe(searchRoot, {attributes: false, childList:true, subtree: true})
console.log("Waiting for " + elementNameForLogging + " to become visible...");
});
}

function triggerPageReloadWhenLogoutButtonIsReplacedWithLoginButton(toolbar) {
const toolbarObserver = new MutationObserver(mutations => {
let loginButton = findFirstElementByXPath(loginButtonXPath, toolbar);
if(loginButton && loginButton.checkVisibility()) {
console.log("Observer found login button. Either this is on page load or logout was done. Re-Checking in 1 second to make sure...");
window.setTimeout(() => {
let stillLoginButton = findFirstElementByXPath(loginButtonXPath, toolbar);
if(stillLoginButton && stillLoginButton.checkVisibility()) {
console.log("Login button still present. Assuming logout was done. Reloading page to retrigger oauth login...");
toolbarObserver.disconnect();
location.reload();
} else {
console.log("Login button is gone now. Assuming page was still loading. Skipping page reload...");
}
}, 1000);
}
});
toolbarObserver.observe(toolbar, {attributes: true, childList:true, subtree: true})
console.log("Waiting for login button to become visible...");
}

waitForElement(toolbarXPath, document, "toolbar").then(toolbar => {
waitForElement(logoutButtonXPath, toolbar, "logout button").then(logoutButton => {
triggerPageReloadWhenLogoutButtonIsReplacedWithLoginButton(toolbar);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import javax.servlet.http.HttpServletRequest;

import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.subject.support.DefaultSubjectContext;
import org.apache.shiro.web.util.WebUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -29,9 +30,8 @@ public class OAuth2ProxyHeaderAuthTokenFactory extends HttpHeaderAuthenticationT
static final String X_FORWARDED_ACCESS_TOKEN = "X-Forwarded-Access-Token";
static final String X_FORWARDED_GROUPS = "X-Forwarded-Groups";

static final List<String> OAUTH2_PROXY_HEADERS = Collections
.unmodifiableList(Arrays.asList(X_FORWARDED_USER, X_FORWARDED_PREFERRED_USERNAME,
X_FORWARDED_EMAIL, X_FORWARDED_ACCESS_TOKEN, X_FORWARDED_GROUPS));
static final List<String> OAUTH2_PROXY_HEADERS = Collections.unmodifiableList(Arrays.asList(X_FORWARDED_USER,
X_FORWARDED_PREFERRED_USERNAME, X_FORWARDED_EMAIL, X_FORWARDED_ACCESS_TOKEN, X_FORWARDED_GROUPS));

@Override
public AuthenticationToken createToken(ServletRequest request, ServletResponse response) {
Expand All @@ -41,54 +41,70 @@ public AuthenticationToken createToken(ServletRequest request, ServletResponse r
String xForwardedEmailHeader = httpRequest.getHeader(X_FORWARDED_EMAIL);
String xForwardedPrefUsernameHeader = httpRequest.getHeader(X_FORWARDED_PREFERRED_USERNAME);

if (httpRequest.getHeader("Authorization") != null) {
logger.debug("not handling requests with Authorization header");
return null;
} else {
if (xForwardedUserHeader == null || xForwardedEmailHeader == null
|| xForwardedPrefUsernameHeader == null) {
logger.debug("required OAuth2 proxy headers incomplete - {}: {} - {}: {} - {}: {}",
X_FORWARDED_USER, xForwardedUserHeader,
X_FORWARDED_EMAIL, xForwardedEmailHeader,
X_FORWARDED_PREFERRED_USERNAME, xForwardedPrefUsernameHeader);
if (xForwardedUserHeader == null || xForwardedEmailHeader == null || xForwardedPrefUsernameHeader == null) {
// any proxy header is missing...
if (httpRequest.getHeader("Authorization") == null) {
// ...and Auth header is missing too -> probably UI based access
logger.debug("required OAuth2 proxy headers incomplete - {}: {} - {}: {} - {}: {}", X_FORWARDED_USER,
xForwardedUserHeader, X_FORWARDED_EMAIL, xForwardedEmailHeader, X_FORWARDED_PREFERRED_USERNAME,
xForwardedPrefUsernameHeader);
return null;
} else {
// ...and Auth header is present -> some access bypassing oauth2 proxy, so we are not responsible
logger.debug("not handling requests with Authorization header, but without any oauth2 proxy headers");
return null;
}
}

OAuth2ProxyHeaderAuthToken token = new OAuth2ProxyHeaderAuthToken();

token.user = new HttpHeaderAuthenticationToken(X_FORWARDED_USER,
xForwardedUserHeader, request.getRemoteHost());
token.user = new HttpHeaderAuthenticationToken(X_FORWARDED_USER, xForwardedUserHeader, request.getRemoteHost());

token.email = new HttpHeaderAuthenticationToken(X_FORWARDED_EMAIL,
xForwardedEmailHeader, request.getRemoteHost());
token.email = new HttpHeaderAuthenticationToken(X_FORWARDED_EMAIL, xForwardedEmailHeader,
request.getRemoteHost());

token.preferred_username = new HttpHeaderAuthenticationToken(X_FORWARDED_PREFERRED_USERNAME,
xForwardedPrefUsernameHeader, request.getRemoteHost());

// (unused) depending on oauth2 proxy config, this might be missing
String accessToken = httpRequest.getHeader(X_FORWARDED_ACCESS_TOKEN);
if (accessToken != null && !accessToken.isEmpty()) {
token.accessToken = new HttpHeaderAuthenticationToken(X_FORWARDED_ACCESS_TOKEN,
accessToken, request.getRemoteHost());
token.accessToken = new HttpHeaderAuthenticationToken(X_FORWARDED_ACCESS_TOKEN, accessToken,
request.getRemoteHost());
}

// depending on oauth2 proxy claims, this might be missing
String groups = httpRequest.getHeader(X_FORWARDED_GROUPS);
if (groups != null && !groups.isEmpty()) {
token.groups = new HttpHeaderAuthenticationToken(X_FORWARDED_GROUPS,
groups,
request.getRemoteHost());
token.groups = new HttpHeaderAuthenticationToken(X_FORWARDED_GROUPS, groups, request.getRemoteHost());
}

token.host = request.getRemoteHost();

logger.debug(
"created token from oauth2 proxy headers: user: {} - preferred_username: {} - email: {} - access token: {} - groups: {}",
token.user.getHeaderValue(), token.preferred_username.getHeaderValue(),
token.email.getHeaderValue(),
token.user.getHeaderValue(), token.preferred_username.getHeaderValue(), token.email.getHeaderValue(),
token.accessToken != null ? "<hidden>" : null,
token.groups != null ? token.groups.getHeaderValue() : null);

if (httpRequest.getHeader("Authorization") == null) {
// "normal" oauth2 login, probably via UI -> create a user session

// NexusBasicHttpAuthenticationFilter which is for reasons Sonatype itself does not know the root of the
// inheritance hierarchy of nexus auth filters turns off shiro session creation by setting this flag to false

// Nexus solely relies on the fact that the session is manually created by POSTing to SessionServlet as part of the login dialog
// As we never get a login dialog, this does not trigger, which means there is no user session ever created.

// While that does not hurt normal login as we continuously login with the oauth proxy anyways,
// it causes the logout button to throw exceptions instead of triggering a LogoutEvent we can listen to for redirecting
// logout to the oauth proxy
request.setAttribute(DefaultSubjectContext.SESSION_CREATION_ENABLED, Boolean.TRUE);
} else {
// most likely programmatic access making use of skip_jwt_bearer_tokens option in oauth2 proxy, meaning an already existing
// jwt token not created (but validated!) by oauth2 proxy is used for login. In this case we don't need a session, so nothing else to do
}

return token;

}
Expand Down
30 changes: 14 additions & 16 deletions src/main/java/com/github/tumbl3w33d/OAuth2ProxyRealm.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@
import org.eclipse.sisu.Description;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.sonatype.nexus.common.event.EventManager;
import org.sonatype.nexus.security.role.RoleIdentifier;
import org.sonatype.nexus.security.user.User;
import org.sonatype.nexus.security.user.UserNotFoundException;

import com.github.tumbl3w33d.h2.OAuth2ProxyLoginRecordStore;
import com.github.tumbl3w33d.h2.OAuth2ProxyRoleStore;
import com.github.tumbl3w33d.logout.OAuth2ProxyLogoutHandler;
import com.github.tumbl3w33d.users.OAuth2ProxyUserManager;
import com.github.tumbl3w33d.users.OAuth2ProxyUserManager.UserWithPrincipals;
import com.github.tumbl3w33d.users.db.OAuth2ProxyLoginRecord;
Expand Down Expand Up @@ -65,10 +67,9 @@ public class OAuth2ProxyRealm extends AuthorizingRealm {
private PasswordService passwordService;

@Inject
public OAuth2ProxyRealm(
@Named OAuth2ProxyUserManager userManager, @Named OAuth2ProxyRoleStore roleStore,
@Named OAuth2ProxyLoginRecordStore loginRecordStore,
@Named PasswordService passwordService) {
public OAuth2ProxyRealm(@Named OAuth2ProxyUserManager userManager, @Named OAuth2ProxyRoleStore roleStore,
@Named OAuth2ProxyLoginRecordStore loginRecordStore, @Named PasswordService passwordService,
EventManager eventManager, OAuth2ProxyLogoutHandler logoutHandler) {
this.userManager = checkNotNull(userManager);
this.roleStore = roleStore;
this.loginRecordStore = loginRecordStore;
Expand All @@ -81,6 +82,8 @@ public OAuth2ProxyRealm(
// authentication is provided by oauth2 proxy headers with every request
setAuthenticationCachingEnabled(false);
setAuthorizationCachingEnabled(false);
eventManager.register(logoutHandler);
logger.debug("Registered oauth2 proxy logout handler");
}

private boolean isApiTokenMatching(AuthenticationToken token) {
Expand Down Expand Up @@ -164,8 +167,7 @@ protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
String userId = userWithPrincipals.getUser().getUserId();

logger.trace("user {} (source {}) has roles {} before sync", userId,
userWithPrincipals.getUser().getSource(),
userWithPrincipals.getUser().getRoles());
userWithPrincipals.getUser().getSource(), userWithPrincipals.getUser().getRoles());

if (oauth2Token.groups != null) {
logger.trace("user {} has identity provider groups {}", userId, oauth2Token.groups);
Expand All @@ -176,8 +178,7 @@ protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)

if (userWithPrincipals.hasPrincipals()) {
logger.debug("found principals for OAuth2 proxy user '{}': '{}' from realms '{}'", oauth2proxyUserId,
userWithPrincipals.getPrincipals(),
userWithPrincipals.getPrincipals().getRealmNames());
userWithPrincipals.getPrincipals(), userWithPrincipals.getPrincipals().getRealmNames());

recordLogin(oauth2proxyUserId);

Expand Down Expand Up @@ -230,26 +231,23 @@ void syncExternalRolesForGroups(User user, String groupsString) {
for (RoleIdentifier role : user.getRoles()) {
if (!role.getSource().equals(OAuth2ProxyUserManager.SOURCE)) {
// not touching roles assigned outside of this realm logic
logger.debug("group sync leaving {}'s role {} untouched", user.getUserId(),
role.getRoleId(), role.getSource());
logger.debug("group sync leaving {}'s role {} untouched", user.getUserId(), role.getRoleId(),
role.getSource());
continue;
}

if (!idpGroups.stream().anyMatch(idpGroup -> idpGroup.getRoleId().equals(role.getRoleId()))) {
logger.warn("marking role {} of user {} for deletion", role.getRoleId(),
user.getUserId());
logger.warn("marking role {} of user {} for deletion", role.getRoleId(), user.getUserId());
rolesToDelete.add(role);
} else {
logger.trace("user {} still has group for role {} in identity provider",
user.getUserId(),
logger.trace("user {} still has group for role {} in identity provider", user.getUserId(),
role.getRoleId());
}
}

for (RoleIdentifier role : rolesToDelete) {
user.removeRole(role);
logger.warn("deleted role {} from user {}", role.getRoleId(),
user.getUserId());
logger.warn("deleted role {} from user {}", role.getRoleId(), user.getUserId());
}

try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.github.tumbl3w33d.logout;

import static com.google.common.base.Preconditions.checkNotNull;

import java.util.Map;

import javax.inject.Inject;
import javax.inject.Named;

import org.sonatype.nexus.capability.CapabilitySupport;

@Named(OAuth2ProxyLogoutCapabilityDescriptor.TYPE_ID)
public class OAuth2ProxyLogoutCapability extends CapabilitySupport<OAuth2ProxyLogoutCapabilityConfiguration> {

private final OAuth2ProxyLogoutCapabilityConfigurationState state;

@Inject
public OAuth2ProxyLogoutCapability(OAuth2ProxyLogoutCapabilityConfigurationState state) {
this.state = checkNotNull(state);
}

@Override
protected OAuth2ProxyLogoutCapabilityConfiguration createConfig(Map<String, String> properties) {
return new OAuth2ProxyLogoutCapabilityConfiguration(properties);
}

@Override
protected void onActivate(OAuth2ProxyLogoutCapabilityConfiguration config) throws Exception {
state.set(config);
}

@Override
protected void onUpdate(OAuth2ProxyLogoutCapabilityConfiguration config) throws Exception {
state.set(config);
}

@Override
protected void onPassivate(OAuth2ProxyLogoutCapabilityConfiguration config) throws Exception {
state.reset();
}

@Override
protected void onRemove(OAuth2ProxyLogoutCapabilityConfiguration config) throws Exception {
state.reset();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.github.tumbl3w33d.logout;

import java.util.Map;
import java.util.Objects;

public class OAuth2ProxyLogoutCapabilityConfiguration {

public static final String LOGOUT_URL_ID = "oauth2-proxy-logout-url";
public static final String LOGOUT_URL_LABEL = "OAuth2 Proxy logout url";
public static final String LOGOUT_URL_HELP = "URL to be called for backchannel logout in OAuth2 Proxy when the Nexus logout button is pressed. Defaults to '{nexus-base-url}/oauth2/sign_out' if not specified";

private String logoutUrl;

public OAuth2ProxyLogoutCapabilityConfiguration(Map<String, String> properties) {
if (properties != null) {
logoutUrl = properties.get(LOGOUT_URL_ID);
}
}

public String getLogoutUrl() {
return logoutUrl;
}

public void setLogoutUrl(String logoutUrl) {
this.logoutUrl = logoutUrl;
}

@Override
public int hashCode() {
return Objects.hash(logoutUrl);
}

@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
OAuth2ProxyLogoutCapabilityConfiguration other = (OAuth2ProxyLogoutCapabilityConfiguration) obj;
return Objects.equals(logoutUrl, other.logoutUrl);
}

@Override
public String toString() {
return "OAuth2ProxyLogoutCapabilityConfiguration [logoutUrl=" + logoutUrl + "]";
}

}
Loading

0 comments on commit 25f9b12

Please sign in to comment.