Skip to content

Commit

Permalink
Add custom parameters to authorize and logout endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
eva-mueller-coremedia committed Dec 25, 2024
1 parent c62804f commit 5c28d5f
Show file tree
Hide file tree
Showing 17 changed files with 525 additions and 22 deletions.
16 changes: 16 additions & 0 deletions docs/configuration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,16 @@ They are called claims in OpenID Connect terminology.
| emailFieldName | jmes path | claim to use for populating user email |
| groupsFieldName | jmes path | groups the user belongs to |

## Custom Query Parameters For Login and Logout Endpoints

Optional list of key / value query parameter pairs which will be appended
when calling the login resp. the logout endpoint.

| field | format | description |
|-----------------|--------|--------------------------------------------------------------------|
| queryParamName | string | Name of the query parameter. |
| queryParamValue | string | Value of the query parameter. If empty, only the key will be sent. |


## JCasC configuration reference

Expand Down Expand Up @@ -142,6 +152,12 @@ jenkins:
rootURLFromRequest: <boolean>
sendScopesInTokenRequest: <boolean>
postLogoutRedirectUrl: <url>
loginQueryParamNameValuePairs:
- queryParamName: <string>
queryParamValue: <string>
logoutQueryParamNameValuePairs:
- queryParamName: <string>
queryParamValue: <string>
# Security
allowTokenAccessWithoutOicSession: <boolean>
allowedTokenExpirationClockSkewSeconds: <integer>
Expand Down
Binary file modified docs/images/global-config.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package org.jenkinsci.plugins.oic;

import hudson.Extension;
import hudson.Util;
import hudson.model.AbstractDescribableImpl;
import hudson.model.Descriptor;
import hudson.util.FormValidation;
import java.io.Serializable;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import jenkins.model.Jenkins;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.DataBoundSetter;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.verb.POST;
import org.springframework.lang.NonNull;

public class OicQueryParameterConfiguration extends AbstractDescribableImpl<OicQueryParameterConfiguration>
implements Serializable {

private static final long serialVersionUID = 1L;

private String paramName;
private String paramValue;

@DataBoundConstructor
public OicQueryParameterConfiguration() {}

public OicQueryParameterConfiguration(@NonNull String paramName, @NonNull String paramValue) {
if (Util.fixEmptyAndTrim(paramName) == null) {
throw new IllegalStateException("Parameter name '" + paramName + "' must not be null or empty.");
}
setQueryParamName(paramName.trim());
setQueryParamValue(paramValue.trim());
}

@DataBoundSetter
public void setQueryParamName(String paramName) {
this.paramName = paramName;
}

@DataBoundSetter
public void setQueryParamValue(String paramValue) {
this.paramValue = paramValue;
}

public String getQueryParamName() {
return paramName;
}

public String getQueryParamValue() {
return paramValue;
}

public String getQueryParamNameDecoded() {
return paramName != null ? URLEncoder.encode(paramName, StandardCharsets.UTF_8).trim() : null;
}

public String getQueryParamValueDecoded() {
return paramValue != null ? URLEncoder.encode(paramValue, StandardCharsets.UTF_8).trim() : null;
}

@Extension
public static final class DescriptorImpl extends Descriptor<OicQueryParameterConfiguration> {
@NonNull
@Override
public String getDisplayName() {
return "Query Parameter Configuration";
}

@POST
public FormValidation doCheckQueryParamName(@QueryParameter String queryParamName) {
Jenkins.get().checkPermission(Jenkins.ADMINISTER);
if (Util.fixEmptyAndTrim(queryParamName) == null) {
return FormValidation.error(Messages.OicQueryParameterConfiguration_QueryParameterNameRequired());
}
return FormValidation.ok();
}
}
}
120 changes: 102 additions & 18 deletions src/main/java/org/jenkinsci/plugins/oic/OicSecurityRealm.java
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,18 @@
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Random;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.PostConstruct;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
Expand Down Expand Up @@ -305,6 +310,9 @@ ClientAuthenticationMethod toClientAuthenticationMethod() {
*/
private transient ProxyAwareResourceRetriever proxyAwareResourceRetriever;

private List<OicQueryParameterConfiguration> loginQueryParamNameValuePairs;
private List<OicQueryParameterConfiguration> logoutQueryParamNameValuePairs;

@DataBoundConstructor
public OicSecurityRealm(
String clientId,
Expand Down Expand Up @@ -357,6 +365,9 @@ protected Object readResolve() throws ObjectStreamException {
// ensure escapeHatchSecret is encrypted
this.setEscapeHatchSecret(this.escapeHatchSecret);

this.setLoginQueryParamNameValuePairs(this.loginQueryParamNameValuePairs);
this.setLogoutQueryParamNameValuePairs(this.logoutQueryParamNameValuePairs);

// validate this option in FIPS env or not
try {
this.setEscapeHatchEnabled(this.escapeHatchEnabled);
Expand Down Expand Up @@ -397,6 +408,24 @@ protected Object readResolve() throws ObjectStreamException {
return this;
}

@DataBoundSetter
public void setLoginQueryParamNameValuePairs(List<OicQueryParameterConfiguration> values) {
this.loginQueryParamNameValuePairs = values;
}

public List<OicQueryParameterConfiguration> getLoginQueryParamNameValuePairs() {
return loginQueryParamNameValuePairs;
}

@DataBoundSetter
public void setLogoutQueryParamNameValuePairs(List<OicQueryParameterConfiguration> values) {
this.logoutQueryParamNameValuePairs = values;
}

public List<OicQueryParameterConfiguration> getLogoutQueryParamNameValuePairs() {
return logoutQueryParamNameValuePairs;
}

public String getClientId() {
return clientId;
}
Expand Down Expand Up @@ -505,7 +534,7 @@ ProxyAwareResourceRetriever getResourceRetriever() {
return proxyAwareResourceRetriever;
}

private OidcConfiguration buildOidcConfiguration() {
private OidcConfiguration buildOidcConfiguration(boolean addCustomLoginParams) {
// TODO cache this and use the well known if available.
OidcConfiguration conf = new CustomOidcConfiguration(this.isDisableSslVerification());
conf.setClientId(clientId);
Expand Down Expand Up @@ -534,9 +563,37 @@ private OidcConfiguration buildOidcConfiguration() {
if (this.isPkceEnabled()) {
conf.setPkceMethod(CodeChallengeMethod.S256);
}
if (addCustomLoginParams && loginQueryParamNameValuePairs != null && !loginQueryParamNameValuePairs.isEmpty()) {
Set<String> forbiddenKeys = Set.of(
OidcConfiguration.SCOPE,
OidcConfiguration.RESPONSE_TYPE,
OidcConfiguration.RESPONSE_MODE,
OidcConfiguration.REDIRECT_URI,
OidcConfiguration.CLIENT_ID,
OidcConfiguration.STATE,
OidcConfiguration.MAX_AGE,
OidcConfiguration.PROMPT,
OidcConfiguration.NONCE,
OidcConfiguration.CODE_CHALLENGE,
OidcConfiguration.CODE_CHALLENGE_METHOD);
Map<String, String> customParameterMap =
getCustomParametersMap(loginQueryParamNameValuePairs, forbiddenKeys);
LOGGER.info("Append the following custom parameters to the authorize endpoint: " + customParameterMap);
customParameterMap.forEach(conf::addCustomParam);
}
return conf;
}

Map<String, String> getCustomParametersMap(
List<OicQueryParameterConfiguration> queryParamNameValuePairs, Set<String> forbiddenKeys) {
return queryParamNameValuePairs.stream()
.filter(c -> Util.fixEmptyAndTrim(c.getQueryParamName()) != null)
.filter(c -> !forbiddenKeys.contains(c.getQueryParamNameDecoded()))
.collect(Collectors.toMap(
OicQueryParameterConfiguration::getQueryParamNameDecoded,
OicQueryParameterConfiguration::getQueryParamValueDecoded));
}

// Visible for testing
@Restricted(NoExternalUse.class)
protected void filterNonFIPS140CompliantAlgorithms(@NonNull OIDCProviderMetadata oidcProviderMetadata) {
Expand Down Expand Up @@ -670,8 +727,8 @@ private void filterJwsAlgorithms(@NonNull OIDCProviderMetadata oidcProviderMetad
}

@Restricted(NoExternalUse.class) // exposed for testing only
protected OidcClient buildOidcClient() {
OidcConfiguration oidcConfiguration = buildOidcConfiguration();
protected OidcClient buildOidcClient(boolean addCustomLoginParams) {
OidcConfiguration oidcConfiguration = buildOidcConfiguration(addCustomLoginParams);
OidcClient client = new OidcClient(oidcConfiguration);
// add the extra settings for the client...
client.setCallbackUrl(buildOAuthRedirectUrl());
Expand Down Expand Up @@ -932,7 +989,7 @@ protected String getValidRedirectUrl(String url) {
public void doCommenceLogin(@QueryParameter String from, @Header("Referer") final String referer)
throws URISyntaxException {

OidcClient client = buildOidcClient();
OidcClient client = buildOidcClient(true);
// add the extra params for the client...
final String redirectOnFinish = getValidRedirectUrl(from != null ? from : referer);

Expand Down Expand Up @@ -1172,7 +1229,7 @@ public String getPostLogOutUrl2(StaplerRequest req, Authentication auth) {
@VisibleForTesting
Object getStateAttribute(HttpSession session) {
// return null;
OidcClient client = buildOidcClient();
OidcClient client = buildOidcClient(false);
WebContext webContext =
JEEContextFactory.INSTANCE.newContext(Stapler.getCurrentRequest(), Stapler.getCurrentResponse());
SessionStore sessionStore = JEESessionStoreFactory.INSTANCE.newSessionStore();
Expand All @@ -1183,22 +1240,49 @@ Object getStateAttribute(HttpSession session) {
}

@CheckForNull
private String maybeOpenIdLogoutEndpoint(String idToken, String state, String postLogoutRedirectUrl) {
String maybeOpenIdLogoutEndpoint(String idToken, String state, String postLogoutRedirectUrl) {
final URI url = serverConfiguration.toProviderMetadata().getEndSessionEndpointURI();
if (this.logoutFromOpenidProvider && url != null) {
StringBuilder openidLogoutEndpoint = new StringBuilder(url.toString());

Map<String, String> segmentsMap = new HashMap<>();
Set<String> segmentsSet = new HashSet<>();
if (!Strings.isNullOrEmpty(idToken)) {
openidLogoutEndpoint.append("?id_token_hint=").append(idToken).append("&");
} else {
openidLogoutEndpoint.append("?");
segmentsMap.put("id_token_hint", idToken);
}
if (!Strings.isNullOrEmpty(state) && !"null".equals(state)) {
segmentsMap.put("state", state);
}
openidLogoutEndpoint.append("state=").append(state);

if (postLogoutRedirectUrl != null) {
openidLogoutEndpoint
.append("&post_logout_redirect_uri=")
.append(URLEncoder.encode(postLogoutRedirectUrl, StandardCharsets.UTF_8));
segmentsMap.put(
"post_logout_redirect_uri", URLEncoder.encode(postLogoutRedirectUrl, StandardCharsets.UTF_8));
}
Set<String> forbiddenKeys = Set.of("id_token_hint", "state", "post_logout_redirect_uri");
if (logoutQueryParamNameValuePairs != null && !logoutQueryParamNameValuePairs.isEmpty()) {
Map<String, String> customParameterMap =
getCustomParametersMap(logoutQueryParamNameValuePairs, forbiddenKeys);
LOGGER.info("Append the following custom parameters to the logout endpoint: " + customParameterMap);

customParameterMap.forEach((k, v) -> {
String key = k.trim();
String value = v.trim();
if (value.isEmpty()) {
segmentsSet.add(key);
} else {
segmentsMap.put(key, value);
}
});
}

StringBuilder openidLogoutEndpoint = new StringBuilder(url.toString());
String concatChar = openidLogoutEndpoint.toString().contains("?") ? "&" : "?";
if (!segmentsMap.isEmpty()) {
String joinedString = segmentsMap.entrySet().stream()
.map(entry -> entry.getKey() + "=" + entry.getValue())
.collect(Collectors.joining("&"));
openidLogoutEndpoint.append(concatChar).append(joinedString);
concatChar = "&";
}
if (!segmentsSet.isEmpty()) {
openidLogoutEndpoint.append(concatChar).append(String.join("&", segmentsSet));
}
return openidLogoutEndpoint.toString();
}
Expand Down Expand Up @@ -1243,7 +1327,7 @@ private String buildOAuthRedirectUrl() throws NullPointerException {
* @throws ParseException if the JWT (or other response) could not be parsed.
*/
public void doFinishLogin(StaplerRequest request, StaplerResponse response) throws IOException, ParseException {
OidcClient client = buildOidcClient();
OidcClient client = buildOidcClient(false);

WebContext webContext = JEEContextFactory.INSTANCE.newContext(request, response);
SessionStore sessionStore = JEESessionStoreFactory.INSTANCE.newSessionStore();
Expand Down Expand Up @@ -1386,7 +1470,7 @@ private boolean refreshExpiredToken(

WebContext webContext = JEEContextFactory.INSTANCE.newContext(httpRequest, httpResponse);
SessionStore sessionStore = JEESessionStoreFactory.INSTANCE.newSessionStore();
OidcClient client = buildOidcClient();
OidcClient client = buildOidcClient(false);
// PAC4J maintains the nonce even though servers should not respond with an id token containing the nonce
// https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse
// it SHOULD NOT have a nonce Claim, even when the ID Token issued at the time of the original authentication
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
OicLogoutAction.OicLogout = Oic Logout

OicQueryParameterConfiguration.QueryParameterNameRequired = Query parameter name is required.
OicQueryParameterConfiguration.QueryParameterValueRequired = Query parameter value is required.

OicSecurityRealm.DisplayName = Login with Openid Connect
OicSecurityRealm.CouldNotRefreshToken = Unable to refresh access token
OicSecurityRealm.ClientIdRequired = Client id is required.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form">
<f:entry title="${%QueryParameterName}" field="queryParamName">
<f:textbox />
</f:entry>
<f:entry title="${%QueryParameterValue}" field="queryParamValue">
<f:textbox />
</f:entry>
</j:jelly>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
QueryParameterName=Query Parameter Name
QueryParameterValue=Query Parameter Value
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div>
Additional custom query parameters added to a URL.
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,28 @@
<f:textbox/>
</f:entry>
</f:advanced>
<f:advanced title="${%LoginLogoutQueryParametersTitle}">
<f:entry title="${%LoginQueryParametersTitle}">
<f:repeatable field="loginQueryParamNameValuePairs"
header="${%LoginLogoutQueryParamNameValuePairs.header}"
minimum="0"
add="${%LoginQueryParamNameValuePairs.add}">
<st:include page="config.jelly"
class="org.jenkinsci.plugins.oic.OicQueryParameterConfiguration"/>
<div align="right"><f:repeatableDeleteButton/></div>
</f:repeatable>
</f:entry>
<f:entry title="${%LogoutQueryParametersTitle}">
<f:repeatable field="logoutQueryParamNameValuePairs"
header="${%LoginLogoutQueryParamNameValuePairs.header}"
minimum="0"
add="${%LogoutQueryParamNameValuePairs.add}">
<st:include page="config.jelly"
class="org.jenkinsci.plugins.oic.OicQueryParameterConfiguration"/>
<div align="right"><f:repeatableDeleteButton/></div>
</f:repeatable>
</f:entry>
</f:advanced>
<f:entry title="${%LogoutFromOpenIDProvider}" field="logoutFromOpenidProvider">
<f:checkbox id="logoutFromIDP"/>
</f:entry>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ EnablePKCE=Enable Proof Key for Code Exchange (PKCE)
FullnameFieldName=Full name field name
Group=Group
GroupsFieldName=Groups field name
LoginLogoutQueryParametersTitle=Query Parameters for Login and Logout Endpoints
LoginLogoutQueryParamNameValuePairs.header=Query Parameter
LoginQueryParametersTitle=Query Parameters for Login Endpoint
LoginQueryParamNameValuePairs.add=Add Login Query Parameter
LogoutQueryParametersTitle=Query Parameters for Logout Endpoint
LogoutQueryParamNameValuePairs.add=Add Logout Query Parameter
LogoutFromOpenIDProvider=Logout from OpenID Provider
PostLogoutRedirectUrl=Post logout redirect URL
Secret=Secret
Expand Down
Loading

0 comments on commit 5c28d5f

Please sign in to comment.