-
Notifications
You must be signed in to change notification settings - Fork 166
Add OAuth2ClientInterceptor to handle client credentials flow #2969
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
christiangoerdes
wants to merge
10
commits into
master
Choose a base branch
from
oAuth2Client-interceptor
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
47f8226
Add OAuth2ClientInterceptor to handle client credentials flow
christiangoerdes efbbb5f
Implement access token caching in OAuth2ClientInterceptor to reduce r…
christiangoerdes 108797a
Simplify error detail message in OAuth2ClientInterceptor for token re…
christiangoerdes 4fbd671
Encode client credentials in OAuth2ClientInterceptor to ensure proper…
christiangoerdes f9b2f28
Add Javadocs for OAuth2ClientInterceptor configuration attributes and…
christiangoerdes 65afe3d
Refactor `OAuth2ClientInterceptor` for improved readability and token…
christiangoerdes 4def1a3
Refactor OAuth2 token handling: replace manual query string construct…
christiangoerdes f76cf74
Refactor authorization header creation: centralize logic in `BasicAut…
christiangoerdes 996c307
Call super.init() in OAuth2ClientInterceptor init method
christiangoerdes edb27be
Add proxy authentication check in HttpClient before attaching credent…
christiangoerdes File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
232 changes: 232 additions & 0 deletions
232
core/src/main/java/com/predic8/membrane/core/interceptor/oauth2/OAuth2ClientInterceptor.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,232 @@ | ||
| package com.predic8.membrane.core.interceptor.oauth2; | ||
|
|
||
| import com.fasterxml.jackson.databind.JsonNode; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.predic8.membrane.annot.MCAttribute; | ||
| import com.predic8.membrane.annot.MCElement; | ||
| import com.predic8.membrane.annot.Required; | ||
| import com.predic8.membrane.core.exchange.Exchange; | ||
| import com.predic8.membrane.core.interceptor.AbstractInterceptor; | ||
| import com.predic8.membrane.core.interceptor.Outcome; | ||
| import com.predic8.membrane.core.transport.http.HttpClient; | ||
| import com.predic8.membrane.core.util.security.BasicAuthenticationUtil; | ||
| import org.jetbrains.annotations.NotNull; | ||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
|
|
||
| import static com.predic8.membrane.core.exceptions.ProblemDetails.gateway; | ||
| import static com.predic8.membrane.core.http.Header.ACCEPT; | ||
| import static com.predic8.membrane.core.http.Header.AUTHORIZATION; | ||
| import static com.predic8.membrane.core.http.MimeType.APPLICATION_JSON; | ||
| import static com.predic8.membrane.core.http.MimeType.APPLICATION_X_WWW_FORM_URLENCODED; | ||
| import static com.predic8.membrane.core.http.Request.post; | ||
| import static com.predic8.membrane.core.interceptor.Interceptor.Flow.Set.REQUEST_FLOW; | ||
| import static com.predic8.membrane.core.interceptor.Outcome.ABORT; | ||
| import static com.predic8.membrane.core.interceptor.Outcome.CONTINUE; | ||
| import static com.predic8.membrane.core.util.URLParamUtil.createQueryStringOmitNullValues; | ||
| import static com.predic8.membrane.core.util.security.BasicAuthenticationUtil.createAuthorizationHeader; | ||
| import static java.lang.Math.max; | ||
| import static java.lang.System.currentTimeMillis; | ||
| import static java.net.URLEncoder.encode; | ||
| import static java.nio.charset.StandardCharsets.UTF_8; | ||
|
|
||
| /** | ||
| * @description Obtains an OAuth2 access token using the client credentials flow and forwards the request with a Bearer token. | ||
| * @yaml <pre><code> | ||
| * api: | ||
| * port: 2000 | ||
| * flow: | ||
| * - oauth2Client: | ||
| * tokenUrl: https://auth.example.com/oauth2/token | ||
| * clientId: gateway | ||
| * clientSecret: secret | ||
| * scope: read write | ||
| * target: | ||
| * url: https://api.example.com | ||
| * </code></pre> | ||
| */ | ||
| @MCElement(name="oauth2Client") | ||
| public class OAuth2ClientInterceptor extends AbstractInterceptor { | ||
|
|
||
| private static final Logger log = LoggerFactory.getLogger(OAuth2ClientInterceptor.class); | ||
|
|
||
| private String tokenUrl; | ||
|
|
||
| private String clientId; | ||
|
|
||
| private String clientSecret; | ||
|
|
||
| private String scope; | ||
|
|
||
| private final ObjectMapper objectMapper = new ObjectMapper(); | ||
|
|
||
| private HttpClient httpClient; | ||
| private final Object tokenLock = new Object(); | ||
|
|
||
| private volatile String cachedAccessToken; | ||
| private volatile long cachedAccessTokenValidUntilEpochMillis; | ||
|
|
||
| @Override | ||
| public void init() { | ||
| super.init(); | ||
| name = "OAuth2 Client"; | ||
| setAppliedFlow(REQUEST_FLOW); | ||
|
|
||
| httpClient = router.getHttpClientFactory().createClient(router.getHttpClientConfig()); | ||
| } | ||
|
|
||
| @Override | ||
| public Outcome handleRequest(Exchange exc) { | ||
| try { | ||
| String token = getAccessToken(); | ||
| exc.getRequest().getHeader().setValue(AUTHORIZATION, "Bearer " + token); | ||
| return CONTINUE; | ||
| } catch (Exception e) { | ||
| log.warn("Could not obtain OAuth2 access token from {}: {}", tokenUrl, e.getMessage()); | ||
| log.debug("OAuth2 token request failed.", e); | ||
| gateway(router.getConfiguration().isProduction(), getDisplayName()) | ||
| .title("Bad Gateway") | ||
| .status(502) | ||
| .addSubSee("oauth2-token") | ||
| .detail("Could not obtain an OAuth2 access token.") | ||
| .buildAndSetResponse(exc); | ||
|
christiangoerdes marked this conversation as resolved.
|
||
| return ABORT; | ||
| } | ||
| } | ||
|
|
||
| private String getAccessToken() throws Exception { | ||
| if (hasValidCachedToken()) { | ||
| return cachedAccessToken; | ||
| } | ||
|
|
||
| synchronized (tokenLock) { | ||
| if (hasValidCachedToken()) { | ||
| return cachedAccessToken; | ||
| } | ||
| return fetchAccessToken(); | ||
| } | ||
| } | ||
|
|
||
| private boolean hasValidCachedToken() { | ||
| return cachedAccessToken != null && currentTimeMillis() < cachedAccessTokenValidUntilEpochMillis; | ||
| } | ||
|
|
||
| private String fetchAccessToken() throws Exception { | ||
| Exchange tokenExchange = post(tokenUrl) | ||
| .contentType(APPLICATION_X_WWW_FORM_URLENCODED) | ||
| .header(ACCEPT, APPLICATION_JSON) | ||
| .header(AUTHORIZATION, buildBasicAuthorization()) | ||
| .body(buildTokenRequestBody()) | ||
| .buildExchange(); | ||
|
|
||
| httpClient.call(tokenExchange); | ||
|
|
||
| var response = tokenExchange.getResponse(); | ||
| String responseBody = response.getBodyAsStringDecoded(); | ||
| if (response.getStatusCode() != 200) { | ||
| throw new IllegalStateException("Authorization server returned status " + response.getStatusCode() + "."); | ||
| } | ||
|
|
||
| var responseJson = objectMapper.readTree(responseBody); | ||
|
christiangoerdes marked this conversation as resolved.
|
||
| String token = extractAccessToken(responseJson); | ||
|
|
||
| updateTokenCache(token, responseJson.path("expires_in").asLong(-1)); | ||
| return token; | ||
| } | ||
|
|
||
| private static @NotNull String extractAccessToken(JsonNode responseJson) { | ||
| String token = responseJson.path("access_token").asText(null); | ||
| if (token == null || token.isBlank()) { | ||
| throw new IllegalStateException("Authorization server did not return an access token."); | ||
| } | ||
| return token; | ||
| } | ||
|
|
||
| private void updateTokenCache(String token, long expiresInSeconds) { | ||
| if (expiresInSeconds <= 0) { | ||
| cachedAccessToken = null; | ||
| cachedAccessTokenValidUntilEpochMillis = 0; | ||
| log.debug("Token response from {} has no usable expires_in. Token will not be cached.", tokenUrl); | ||
| return; | ||
| } | ||
|
|
||
| // Refresh slightly before expiry to avoid sending a token that expires mid-request. | ||
| long refreshBufferSeconds = Math.min(30, max(1, expiresInSeconds / 10)); | ||
|
|
||
| cachedAccessToken = token; | ||
| cachedAccessTokenValidUntilEpochMillis = currentTimeMillis() + max(1, expiresInSeconds - refreshBufferSeconds) * 1000; | ||
| } | ||
|
|
||
| private String buildTokenRequestBody() { | ||
| return createQueryStringOmitNullValues( | ||
| "grant_type", "client_credentials", | ||
| "scope", scope == null || scope.isBlank() ? null : scope | ||
| ); | ||
| } | ||
|
|
||
| private String buildBasicAuthorization() { | ||
|
christiangoerdes marked this conversation as resolved.
|
||
| return createAuthorizationHeader(clientId, clientSecret, this::encodeClientCredential); | ||
| } | ||
|
|
||
| private String encodeClientCredential(String value) { | ||
| return encode(value, UTF_8); | ||
| } | ||
|
|
||
| /** | ||
| * @description The token endpoint used to obtain the OAuth2 access token. | ||
| * @required | ||
| * @example https://auth.example.com/oauth2/token | ||
| */ | ||
| @MCAttribute | ||
| @Required | ||
| public void setTokenUrl(String tokenUrl) { | ||
| this.tokenUrl = tokenUrl; | ||
| } | ||
|
|
||
| public String getTokenUrl() { | ||
| return tokenUrl; | ||
| } | ||
|
|
||
| /** | ||
| * @description The OAuth2 client id used for the token request. | ||
| * @required | ||
| * @example gateway | ||
| */ | ||
| @MCAttribute | ||
| @Required | ||
| public void setClientId(String clientId) { | ||
| this.clientId = clientId; | ||
| } | ||
|
|
||
| public String getClientId() { | ||
| return clientId; | ||
| } | ||
|
|
||
| /** | ||
| * @description The OAuth2 client secret used for the token request. | ||
| * @required | ||
| * @example secret | ||
| */ | ||
| @MCAttribute | ||
| @Required | ||
| public void setClientSecret(String clientSecret) { | ||
| this.clientSecret = clientSecret; | ||
| } | ||
|
|
||
| public String getClientSecret() { | ||
| return clientSecret; | ||
| } | ||
|
|
||
| /** | ||
| * @description Space-separated scopes requested for the access token. | ||
| * @example read write | ||
| */ | ||
| @MCAttribute | ||
| public void setScope(String scope) { | ||
| this.scope = scope; | ||
| } | ||
|
|
||
| public String getScope() { | ||
| return scope; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.