Skip to content

Commit

Permalink
Supports Endsession endpoint for microsoft login (#1876)
Browse files Browse the repository at this point in the history
  • Loading branch information
sdelamo authored Dec 2, 2024
1 parent e2e431b commit d3e5ecf
Show file tree
Hide file tree
Showing 8 changed files with 207 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ public enum AuthorizationServer {
ORACLE_CLOUD,
COGNITO,
KEYCLOAK,
AUTH0;
AUTH0,
MICROSOFT;

/**
* @param issuer Issuer url
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
final class DefaultAuthorizationServerResolver implements AuthorizationServerResolver {
private static final String ISSUER_PART_OKTA = "okta";
private static final String ISSUER_PART_ORACLE_CLOUD = "oraclecloud";
private static final String ISSUER_PART_MICROSOFT = "login.microsoftonline.com";
private static final String ISSUER_PART_COGNITO = "cognito";
private static final String ISSUER_PART_AUTH0 = "auth0";
private static final String ISSUER_PART_KEYCLOAK = "/auth/realms/";
Expand All @@ -48,6 +49,9 @@ public Optional<AuthorizationServer> resolve(@NonNull String issuer) {

@Nullable
static AuthorizationServer infer (@NonNull String issuer) {
if (issuer.contains(ISSUER_PART_MICROSOFT)) {
return AuthorizationServer.MICROSOFT;
}
if (issuer.contains(ISSUER_PART_ORACLE_CLOUD)) {
return AuthorizationServer.ORACLE_CLOUD;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ private Optional<EndSessionEndpoint> getEndSessionEndpoint(OauthClientConfigurat
return Optional.empty();
}
switch (inferOptional.get()) {
case MICROSOFT:
if (LOG.isDebugEnabled()) {
LOG.debug("Resolved the MicrosoftEndSessionEndpoint for provider [{}]", providerName);
}
return Optional.of(new MicrosoftEndSessionEndpoint(endSessionCallbackUrlBuilder, oauthClientConfiguration, openIdProviderMetadata));

// Oracle Cloud Logout https://docs.oracle.com/en/cloud/paas/identity-cloud/rest-api/op-oauth2-v1-userlogout-get.html
case ORACLE_CLOUD, OKTA:
if (LOG.isDebugEnabled()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Copyright 2017-2023 original authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.security.oauth2.endpoint.endsession.request;

import io.micronaut.core.annotation.Internal;
import io.micronaut.http.HttpRequest;
import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.oauth2.client.OpenIdProviderMetadata;
import io.micronaut.security.oauth2.configuration.OauthClientConfiguration;
import io.micronaut.security.oauth2.endpoint.endsession.response.EndSessionCallbackUrlBuilder;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;

/**
* An {@link EndSessionEndpoint} which redirects to the Microsoft end session endpoint obtained via the OpenID Connect Configuration.
* It adds two query value parameters {@code post_logout_redirect_uri} and {@code logout_hint}.
* {@code post_logout_redirect_uri} The URL that the user is redirected to after successfully signing out. If the parameter isn't included, the user is shown a generic message that's generated by the Microsoft identity platform. This URL must match one of the redirect URIs registered for your application in the app registration portal.
* {@code logout_hint} Enables sign-out to occur without prompting the user to select an account. To use logout_hint, enable the login_hint optional claim in your client application and use the value of the login_hint optional claim as the logout_hint parameter.
* <a href="https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc#send-a-sign-out-request">Send a sign-out request</a>
*/
@Internal
final class MicrosoftEndSessionEndpoint extends AbstractEndSessionRequest {
private static final String PARAM_POST_LOGOUT_REDIRECT_URI = "post_logout_redirect_uri";
private static final String CLAIM_LOGIN_HINT = "login_hint";
private static final String CLAIM_LOGOUT_HINT = "logout_hint";

/**
* @param endSessionCallbackUrlBuilder The end session callback URL builder
* @param clientConfiguration The client configuration
* @param providerMetadata The provider metadata supplier
*/
MicrosoftEndSessionEndpoint(EndSessionCallbackUrlBuilder endSessionCallbackUrlBuilder,
OauthClientConfiguration clientConfiguration,
Supplier<OpenIdProviderMetadata> providerMetadata) {
super(endSessionCallbackUrlBuilder, clientConfiguration, providerMetadata);
}

@Override
protected String getUrl() {
return providerMetadataSupplier.get().getEndSessionEndpoint();
}

@Override
protected Map<String, Object> getArguments(HttpRequest<?> originating,
Authentication authentication) {
Map<String, Object> arguments = new HashMap<>();
arguments.put(PARAM_POST_LOGOUT_REDIRECT_URI, getRedirectUri(originating));
Object loginHint = authentication.getAttributes().get(CLAIM_LOGIN_HINT);
if (loginHint != null) {
arguments.put(CLAIM_LOGOUT_HINT, loginHint);
}
return arguments;
}
}


Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class AuthorizationServerSpec extends Specification {
"https://cognito-idp.us-east-1.amazonaws.com/12345}/" || AuthorizationServer.COGNITO
"https://micronautguides.eu.auth0.com" || AuthorizationServer.AUTH0
"https://identity.oraclecloud.com/" || AuthorizationServer.ORACLE_CLOUD
"https://login.microsoftonline.com/8177030d-4c56-3c4a-a111-15a102c55cba/v2.0" || AuthorizationServer.MICROSOFT
}

void "Infer authorization server based on the issuer url may return empty Optional"() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class AuthorizationServerResolverTest {

static Stream<Arguments> paramsProvider() {
return Stream.of(
arguments("https://login.microsoftonline.com/8177030d-4c56-3c4a-a111-15a102c55cba/v2.0", AuthorizationServer.MICROSOFT),
arguments("http://localhost:8180/auth/realms/master", AuthorizationServer.KEYCLOAK),
arguments("https://dev-XXXXX.oktapreview.com/oauth2/default", AuthorizationServer.OKTA),
arguments("https://cognito-idp.us-east-1.amazonaws.com/12345}/", AuthorizationServer.COGNITO),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package io.micronaut.security.oauth2.endpoint.endsession.request;

import io.micronaut.context.ApplicationContext;
import io.micronaut.context.annotation.Replaces;
import io.micronaut.context.annotation.Requires;
import io.micronaut.http.HttpMethod;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.simple.SimpleHttpRequest;
import io.micronaut.http.uri.UriBuilder;
import io.micronaut.inject.qualifiers.Qualifiers;
import io.micronaut.runtime.server.EmbeddedServer;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.oauth2.client.OpenIdClient;
import io.micronaut.security.oauth2.client.OpenIdProviderMetadata;
import io.micronaut.security.oauth2.configuration.OauthClientConfiguration;
import io.micronaut.security.oauth2.endpoint.endsession.response.EndSessionCallbackUrlBuilder;
import io.micronaut.security.rules.SecurityRule;
import jakarta.inject.Singleton;
import org.junit.jupiter.api.Test;

import java.net.URI;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

class MicrosoftEndSessionEndpointTest {
private static final String LOGOUT = "https://login.microsoftonline.com/8177030d-4c56-3c4a-a111-15a102c55cba/oauth2/v2.0/logout";
private static final String OPENID_CONFIG = """
{
"token_endpoint":"https://login.microsoftonline.com/8177030d-4c56-3c4a-a111-15a102c55cba/oauth2/v2.0/token",
"token_endpoint_auth_methods_supported":["client_secret_post","private_key_jwt","client_secret_basic"],
"jwks_uri":"https://login.microsoftonline.com/8177030d-4c56-3c4a-a111-15a102c55cba/discovery/v2.0/keys",
"response_modes_supported":["query","fragment","form_post"],
"subject_types_supported":["pairwise"],
"id_token_signing_alg_values_supported":["RS256"],
"response_types_supported":["code","id_token","code id_token","id_token token"],
"scopes_supported":["openid","profile","email","offline_access"],
"issuer":"https://login.microsoftonline.com/8177030d-4c56-3c4a-a111-15a102c55cba/v2.0",
"request_uri_parameter_supported":false,
"userinfo_endpoint":"https://graph.microsoft.com/oidc/userinfo",
"authorization_endpoint":"https://login.microsoftonline.com/8177030d-4c56-3c4a-a111-15a102c55cba/oauth2/v2.0/authorize",
"device_authorization_endpoint":"https://login.microsoftonline.com/8177030d-4c56-3c4a-a111-15a102c55cba/oauth2/v2.0/devicecode",
"http_logout_supported":true,
"frontchannel_logout_supported":true,
"end_session_endpoint":"https://login.microsoftonline.com/8177030d-4c56-3c4a-a111-15a102c55cba/oauth2/v2.0/logout",
"claims_supported":["sub","iss","cloud_instance_name","cloud_instance_host_name","cloud_graph_host_name","msgraph_host","aud","exp","iat","auth_time","acr","nonce","preferred_username","name","tid","ver","at_hash","c_hash","email"],
"kerberos_endpoint":"https://login.microsoftonline.com/8177030d-4c56-3c4a-a111-15a102c55cba/kerberos",
"tenant_region_scope":null,
"cloud_instance_name":"microsoftonline.com",
"cloud_graph_host_name":"graph.windows.net",
"msgraph_host":"graph.microsoft.com",
"rbac_url":"https://pas.windows.net"
}""";

@Test
void oracleCloudConfigurationSupportsEndSession() {
String nameQualifier = "microsoft";
try (EmbeddedServer authServer = ApplicationContext.run(EmbeddedServer.class,
Map.of("spec.name", "MicrosoftEndSessionEndpointTestAuthServer"))) {
try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class,
Map.of("spec.name", "MicrosoftEndSessionEndpointTest",
"micronaut.security.oauth2.clients." + nameQualifier + ".openid.issuer", authServer.getURL().toString(),
"micronaut.security.oauth2.clients." + nameQualifier + ".client-secret", "yyy",
"micronaut.security.oauth2.clients." + nameQualifier + ".client-id", "xxx"
))) {
var openIdClient = server.getApplicationContext().getBean(OpenIdClient.class, Qualifiers.byName(nameQualifier));
assertTrue(openIdClient.supportsEndSession());
var endSessionEndpointResolver = server.getApplicationContext().getBean(EndSessionEndpointResolver.class);
var oauthClientConfiguration = server.getApplicationContext().getBean(OauthClientConfiguration.class, Qualifiers.byName(nameQualifier));
var openIdProviderMetadata = server.getApplicationContext().getBean(OpenIdProviderMetadata.class);
var endSessionCallbackUrlBuilder = server.getApplicationContext().getBean(EndSessionCallbackUrlBuilder.class);
Optional<EndSessionEndpoint> endSessionEndpointOptional = endSessionEndpointResolver.resolve(oauthClientConfiguration, openIdProviderMetadata, endSessionCallbackUrlBuilder);
assertTrue(endSessionEndpointOptional.isPresent());
EndSessionEndpoint endSessionEndpoint = endSessionEndpointOptional.get();

// if no login_hint is provided, only post_logout_redirect_uri is added
Authentication authentication = Authentication.build("sherlock");
String url = endSessionEndpoint.getUrl(new SimpleHttpRequest<>(HttpMethod.GET, "/foo/bar", Collections.emptyMap()), authentication);
String expected = UriBuilder.of(LOGOUT)
.queryParam("post_logout_redirect_uri", "http://localhost:"+ server.getPort() + "/logout")
.build()
.toString();
assertEquals(expected, url);

// if login_hint is provided, logout_hint is added
authentication = Authentication.build("sherlock", Collections.singletonMap("login_hint", "xyz"));
url = endSessionEndpoint.getUrl(new SimpleHttpRequest<>(HttpMethod.GET, "/foo/bar", Collections.emptyMap()), authentication);
URI expectedURI = UriBuilder.of(LOGOUT)
.queryParam("logout_hint", "xyz")
.queryParam("post_logout_redirect_uri", "http://localhost:"+ server.getPort() + "/logout")
.build();
assertEquals(expectedURI, URI.create(url));
}
}
}

@Requires(property = "spec.name", value = "MicrosoftEndSessionEndpointTest")
@Singleton
@Replaces(AuthorizationServerResolver.class)
static class AuthorizationServerResolverReplacement implements AuthorizationServerResolver {
@Override
public Optional<AuthorizationServer> resolve(String issuer) {
return Optional.of(AuthorizationServer.MICROSOFT);
}
}

@Requires(property = "spec.name", value = "MicrosoftEndSessionEndpointTestAuthServer")
@Controller
static class OpenidConfigurationController {
@Secured(SecurityRule.IS_ANONYMOUS)
@Get("/.well-known/openid-configuration")
String index() {
return OPENID_CONFIG;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public final class ClaimsUtils {
private static final String EMPTY = "";
private static final String SLASH = "/";

private static final Map<ClaimPair, Boolean> cache = new HashMap<>();
private static final Map<ClaimPair, Boolean> CACHE = new HashMap<>();

private ClaimsUtils() {
}
Expand All @@ -45,7 +45,7 @@ private ClaimsUtils() {
*/
public static boolean endsWithIgnoringProtocolAndTrailingSlash(@NonNull String expectedClaim, @NonNull String claim) {
ClaimPair pair = new ClaimPair(expectedClaim, claim);
return cache.computeIfAbsent(pair, claimPair ->
return CACHE.computeIfAbsent(pair, claimPair ->
removeLeadingProtocolAndTrailingSlash(claimPair.expectedClaim())
.endsWith(removeLeadingProtocolAndTrailingSlash(claimPair.claim())));
}
Expand Down

0 comments on commit d3e5ecf

Please sign in to comment.