diff --git a/src/main/java/run/halo/oauth/HaloOAuth2AuthenticationWebFilter.java b/src/main/java/run/halo/oauth/HaloOAuth2AuthenticationWebFilter.java index 2e37506..60110e5 100644 --- a/src/main/java/run/halo/oauth/HaloOAuth2AuthenticationWebFilter.java +++ b/src/main/java/run/halo/oauth/HaloOAuth2AuthenticationWebFilter.java @@ -8,6 +8,7 @@ import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.security.authentication.DelegatingReactiveAuthenticationManager; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.authentication.OAuth2LoginReactiveAuthenticationManager; import org.springframework.security.oauth2.client.endpoint.WebClientReactiveAuthorizationCodeTokenResponseClient; import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeReactiveAuthenticationManager; @@ -42,6 +43,7 @@ @Component public class HaloOAuth2AuthenticationWebFilter implements AuthenticationSecurityWebFilter { + static final String JWS_ALGORITHM_METADATA_KEY = "jwsAlgorithm"; private final WebFilter delegate; public HaloOAuth2AuthenticationWebFilter(Oauth2LoginConfiguration configuration, @@ -83,25 +85,9 @@ public HaloOAuth2AuthenticationWebFilter(Oauth2LoginConfiguration configuration, new OidcReactiveOAuth2UserService() ); var oidcIdTokenDecodeFactory = new ReactiveOidcIdTokenDecoderFactory(); - oidcIdTokenDecodeFactory.setJwsAlgorithmResolver(clientRegistration -> { - var configurationMetadata = clientRegistration.getProviderDetails() - .getConfigurationMetadata(); - try { - var supportedJwsAlgorithms = JSONObjectUtils.getStringList( - new JSONObject(configurationMetadata), - "id_token_signing_alg_values_supported" - ); - // we choose the first one as JWS algorithm - if (!supportedJwsAlgorithms.isEmpty()) { - var jwsAlgorithm = supportedJwsAlgorithms.get(0); - return SignatureAlgorithm.from(jwsAlgorithm); - } - } catch (ParseException e) { - // ignore the error. - } - // default algorithm - return SignatureAlgorithm.RS256; - }); + oidcIdTokenDecodeFactory.setJwsAlgorithmResolver( + HaloOAuth2AuthenticationWebFilter::resolveJwsAlgorithm + ); oidcAuthManager.setJwtDecoderFactory(oidcIdTokenDecodeFactory); var authManager = new DelegatingReactiveAuthenticationManager(oauth2AuthManager, oidcAuthManager); @@ -136,4 +122,27 @@ public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { return delegate.filter(exchange, chain); } + static SignatureAlgorithm resolveJwsAlgorithm(ClientRegistration clientRegistration) { + var configurationMetadata = clientRegistration.getProviderDetails() + .getConfigurationMetadata(); + var configuredJwsAlgorithm = configurationMetadata.get(JWS_ALGORITHM_METADATA_KEY); + if (configuredJwsAlgorithm instanceof String jwsAlgorithm + && StringUtils.hasText(jwsAlgorithm)) { + return SignatureAlgorithm.from(jwsAlgorithm); + } + try { + var supportedJwsAlgorithms = JSONObjectUtils.getStringList( + new JSONObject(configurationMetadata), + "id_token_signing_alg_values_supported" + ); + // we choose the first one as JWS algorithm + if (!supportedJwsAlgorithms.isEmpty()) { + return SignatureAlgorithm.from(supportedJwsAlgorithms.get(0)); + } + } catch (ParseException | IllegalArgumentException e) { + log.debug("Failed to resolve JWS algorithm from provider metadata.", e); + } + // default algorithm + return SignatureAlgorithm.RS256; + } } diff --git a/src/main/java/run/halo/oauth/Oauth2ClientRegistration.java b/src/main/java/run/halo/oauth/Oauth2ClientRegistration.java index d4491ee..8de13a0 100644 --- a/src/main/java/run/halo/oauth/Oauth2ClientRegistration.java +++ b/src/main/java/run/halo/oauth/Oauth2ClientRegistration.java @@ -3,7 +3,6 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import io.swagger.v3.oas.annotations.media.Schema; -import java.util.Map; import java.util.Set; import lombok.Data; import lombok.EqualsAndHashCode; @@ -54,7 +53,7 @@ public static class Oauth2ClientRegistrationSpec { private String issuerUri; - private Map configurationMetadata; + private String jwsAlgorithm; private String clientName; } diff --git a/src/main/java/run/halo/oauth/OauthClientRegistrationRepository.java b/src/main/java/run/halo/oauth/OauthClientRegistrationRepository.java index 9136ed0..d234017 100644 --- a/src/main/java/run/halo/oauth/OauthClientRegistrationRepository.java +++ b/src/main/java/run/halo/oauth/OauthClientRegistrationRepository.java @@ -38,7 +38,9 @@ @RequiredArgsConstructor public class OauthClientRegistrationRepository implements ReactiveClientRegistrationRepository { static final String DEFAULT_REDIRECT_URL = "{baseUrl}/{action}/oauth2/code/{registrationId}"; - static final String SSO_PROVIDER_NAME = "sso"; + static final String ADVANCED_OIDC_SETTING_GROUP = "ssoOauth"; + static final String LOGTO_SETTING_GROUP = "logtoOauth"; + static final String JWS_ALGORITHM_METADATA_KEY = "jwsAlgorithm"; private final ReactiveExtensionClient client; private final ExternalUrlSupplier externalUrlSupplier; @@ -74,7 +76,20 @@ private Mono getClientRegistrationMono(AuthProvider authProv ) .flatMap(data -> { String value = data.getOrDefault(group, "{}"); - if (SSO_PROVIDER_NAME.equals(name)) { + if (LOGTO_SETTING_GROUP.equals(group)) { + if (StringUtils.isBlank(value) || "{}".equals(value.trim())) { + String legacyValue = data.getOrDefault(ADVANCED_OIDC_SETTING_GROUP, "{}"); + if (!"{}".equals(legacyValue.trim())) { + SsoClientConf ssoClientConf = + JsonUtils.jsonToObject(legacyValue, SsoClientConf.class); + return SsoClientRegistration(ssoClientConf, authProvider); + } + } + LogtoClientConf logtoClientConf = + JsonUtils.jsonToObject(value, LogtoClientConf.class); + return LogtoClientRegistration(logtoClientConf, authProvider); + } + if (ADVANCED_OIDC_SETTING_GROUP.equals(group)) { SsoClientConf ssoClientConf = JsonUtils.jsonToObject(value, SsoClientConf.class); return SsoClientRegistration(ssoClientConf, authProvider); @@ -108,6 +123,27 @@ private Mono GenericClientRegistration(GenericClientConf gen ); } + private Mono LogtoClientRegistration(LogtoClientConf logtoClientConf, + AuthProvider authProvider) { + String registrationId = authProvider.getMetadata().getName(); + var issuerUri = normalizeLogtoIssuerUri(logtoClientConf.endpoint()); + return client.fetch(Oauth2ClientRegistration.class, registrationId) + .switchIfEmpty(Mono.error(new NotFoundException( + "Oauth2 client registration " + registrationId + " not found") + )) + .map(oauth2ClientRegistration -> clientRegistrationBuilder(oauth2ClientRegistration) + .clientId(logtoClientConf.clientId()) + .clientSecret(logtoClientConf.clientSecret()) + .authorizationUri(issuerUri + "/auth") + .tokenUri(issuerUri + "/token") + .userInfoUri(issuerUri + "/me") + .jwkSetUri(issuerUri + "/jwks") + .issuerUri(issuerUri) + .userNameAttributeName("sub") + .build() + ); + } + private Mono SsoClientRegistration(SsoClientConf ssoClientConf, AuthProvider authProvider) { String registrationId = authProvider.getMetadata().getName(); @@ -157,6 +193,20 @@ record GenericClientConf(String clientId, String clientSecret) { } } + record LogtoClientConf(String clientId, String clientSecret, String endpoint) { + LogtoClientConf { + if (StringUtils.isBlank(clientId)) { + throw new IllegalArgumentException("clientId must not be blank"); + } + if (StringUtils.isBlank(clientSecret)) { + throw new IllegalArgumentException("clientSecret must not be blank"); + } + if (StringUtils.isBlank(endpoint)) { + throw new IllegalArgumentException("endpoint must not be blank"); + } + } + } + record SsoClientConf(String clientId, String clientSecret, String authorizationUrl, String tokenUrl, String userInfoUrl, String scopes, String userNameAttribute, String issuerUri, String jwkSetUri) { @@ -239,12 +289,29 @@ ClientRegistration.Builder clientRegistrationBuilder(Oauth2ClientRegistration re toAuthenticationMethod(spec.getUserInfoAuthenticationMethod()) ) .userInfoUri(spec.getUserInfoUri()) - .providerConfigurationMetadata( - defaultIfNull(spec.getConfigurationMetadata(), Map.of()) - ) + .providerConfigurationMetadata(buildProviderConfigurationMetadata(spec)) .userNameAttributeName(spec.getUserNameAttributeName()); } + private Map buildProviderConfigurationMetadata( + Oauth2ClientRegistration.Oauth2ClientRegistrationSpec spec) { + if (StringUtils.isBlank(spec.getJwsAlgorithm())) { + return Map.of(); + } + return Map.of(JWS_ALGORITHM_METADATA_KEY, spec.getJwsAlgorithm()); + } + + private String normalizeLogtoIssuerUri(String endpoint) { + String normalized = StringUtils.removeEnd(endpoint.trim(), "/"); + for (String suffix : List.of("/auth", "/token", "/me", "/jwks")) { + normalized = StringUtils.removeEnd(normalized, suffix); + } + if (!normalized.endsWith("/oidc")) { + normalized = normalized + "/oidc"; + } + return normalized; + } + Mono> fetchEnabledProviders() { return client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG) .map(configMap -> { diff --git a/src/main/resources/extensions/auth-provider.yaml b/src/main/resources/extensions/auth-provider.yaml index 17403b0..f05b0f3 100644 --- a/src/main/resources/extensions/auth-provider.yaml +++ b/src/main/resources/extensions/auth-provider.yaml @@ -80,4 +80,26 @@ spec: name: sso-oauth2-setting group: ssoOauth configMapRef: - name: oauth2-sso-config \ No newline at end of file + name: oauth2-sso-config +--- +apiVersion: auth.halo.run/v1alpha1 +kind: AuthProvider +metadata: + name: logto + labels: + auth.halo.run/auth-binding: "true" +spec: + displayName: Logto + description: Logto is an open-source identity infrastructure for customer and workforce authentication. + logo: /plugins/plugin-oauth2/assets/static/logto.svg + website: https://logto.io + helpPage: https://docs.logto.io/ + authenticationUrl: /oauth2/authorization/logto + bindingUrl: /oauth2/authorization/logto + unbindUrl: /apis/uc.api.auth.halo.run/v1alpha1/user-connections/logto/disconnect + authType: oauth2 + settingRef: + name: logto-oauth2-setting + group: logtoOauth + configMapRef: + name: oauth2-logto-config diff --git a/src/main/resources/extensions/client-registrations.yaml b/src/main/resources/extensions/client-registrations.yaml index 12b8bf5..7775b5d 100644 --- a/src/main/resources/extensions/client-registrations.yaml +++ b/src/main/resources/extensions/client-registrations.yaml @@ -65,3 +65,23 @@ spec: userInfoAuthenticationMethod: "header" userNameAttributeName: "name" clientName: "SSO" +--- +apiVersion: oauth.halo.run/v1alpha1 +kind: Oauth2ClientRegistration +metadata: + name: logto +spec: + clientAuthenticationMethod: "client_secret_basic" + authorizationGrantType: "authorization_code" + redirectUri: "{baseUrl}/login/oauth2/code/logto" + scopes: + - "openid" + - "profile" + - "email" + authorizationUri: "https://example.logto.app/oidc/auth" + tokenUri: "https://example.logto.app/oidc/token" + userInfoUri: "https://example.logto.app/oidc/me" + userInfoAuthenticationMethod: "header" + userNameAttributeName: "sub" + jwsAlgorithm: "ES384" + clientName: "Logto" diff --git a/src/main/resources/extensions/setting.yaml b/src/main/resources/extensions/setting.yaml index 275ca0c..60fb3c7 100644 --- a/src/main/resources/extensions/setting.yaml +++ b/src/main/resources/extensions/setting.yaml @@ -69,3 +69,27 @@ spec: name: jwkSetUri label: "JWK Set URI" help: "The URL to the JWK Set (KEYS) of the OpenID Connect Provider." +--- +apiVersion: v1alpha1 +kind: Setting +metadata: + name: logto-oauth2-setting +spec: + forms: + - group: logtoOauth + label: "Logto OAuth 配置" + formSchema: + - $formkit: text + name: clientId + label: "Client ID" + validation: required:trim + - $formkit: password + name: clientSecret + label: "Client Secret" + validation: required:trim + - $formkit: text + name: endpoint + label: "Endpoint" + validation: required:trim + value: "https://example.logto.app" + help: "填写 Logto 域名、Issuer 或 OIDC 端点,例如 https://auth.example.com、https://auth.example.com/oidc 或 https://auth.example.com/oidc/auth。" diff --git a/src/main/resources/static/logto.svg b/src/main/resources/static/logto.svg new file mode 100644 index 0000000..75453d9 --- /dev/null +++ b/src/main/resources/static/logto.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/test/java/run/halo/oauth/HaloOAuth2AuthenticationWebFilterTest.java b/src/test/java/run/halo/oauth/HaloOAuth2AuthenticationWebFilterTest.java new file mode 100644 index 0000000..717055c --- /dev/null +++ b/src/test/java/run/halo/oauth/HaloOAuth2AuthenticationWebFilterTest.java @@ -0,0 +1,79 @@ +package run.halo.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; + +class HaloOAuth2AuthenticationWebFilterTest { + + @Test + void explicitJwsAlgorithmShouldTakePrecedence() { + var registration = registrationBuilder() + .providerConfigurationMetadata( + Map.of( + "jwsAlgorithm", "ES384", + "id_token_signing_alg_values_supported", List.of("RS256") + ) + ) + .build(); + + assertThat(resolveJwsAlgorithm(registration)).isEqualTo(SignatureAlgorithm.ES384); + } + + @Test + void metadataSupportedAlgorithmsShouldFallbackToFirstValue() { + var registration = registrationBuilder() + .providerConfigurationMetadata( + Map.of("id_token_signing_alg_values_supported", List.of("ES384", "RS256")) + ) + .build(); + + assertThat(resolveJwsAlgorithm(registration)).isEqualTo(SignatureAlgorithm.ES384); + } + + @Test + void shouldFallbackToDefaultRs256WhenMetadataIsMissing() { + var registration = registrationBuilder().build(); + + assertThat(resolveJwsAlgorithm(registration)).isEqualTo(SignatureAlgorithm.RS256); + } + + private static ClientRegistration.Builder registrationBuilder() { + return ClientRegistration.withRegistrationId("logto") + .clientId("client-id") + .clientSecret("client-secret") + .clientName("Logto") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .redirectUri("{baseUrl}/login/oauth2/code/logto") + .authorizationUri("https://logto.example.com/oidc/auth") + .tokenUri("https://logto.example.com/oidc/token") + .userInfoUri("https://logto.example.com/oidc/me") + .jwkSetUri("https://logto.example.com/oidc/jwks") + .userNameAttributeName("sub"); + } + + private static SignatureAlgorithm resolveJwsAlgorithm(ClientRegistration registration) { + try { + Method method = HaloOAuth2AuthenticationWebFilter.class.getDeclaredMethod( + "resolveJwsAlgorithm", ClientRegistration.class + ); + method.setAccessible(true); + return (SignatureAlgorithm) method.invoke(null, registration); + } catch (NoSuchMethodException e) { + fail("Expected resolveJwsAlgorithm(ClientRegistration) helper to exist", e); + } catch (IllegalAccessException | InvocationTargetException e) { + fail("Failed to invoke resolveJwsAlgorithm(ClientRegistration)", e); + } + throw new IllegalStateException("Unreachable"); + } +} diff --git a/src/test/java/run/halo/oauth/Oauth2ExtensionResourcesTest.java b/src/test/java/run/halo/oauth/Oauth2ExtensionResourcesTest.java new file mode 100644 index 0000000..c5f9898 --- /dev/null +++ b/src/test/java/run/halo/oauth/Oauth2ExtensionResourcesTest.java @@ -0,0 +1,60 @@ +package run.halo.oauth; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +class Oauth2ExtensionResourcesTest { + + @Test + void authProviderShouldContainLogtoProvider() throws IOException { + var content = readResource("extensions/auth-provider.yaml"); + + assertThat(content).contains("name: logto"); + assertThat(content).contains("displayName: Logto"); + assertThat(content).contains("authenticationUrl: /oauth2/authorization/logto"); + assertThat(content).contains("name: logto-oauth2-setting"); + assertThat(content).contains("group: logtoOauth"); + assertThat(content).contains("configMapRef:"); + assertThat(content).contains("name: oauth2-logto-config"); + } + + @Test + void clientRegistrationsShouldContainLogtoRegistrationAndExplicitJwsAlgorithm() + throws IOException { + var content = readResource("extensions/client-registrations.yaml"); + + assertThat(content).contains("name: logto"); + assertThat(content).contains("clientName: \"Logto\""); + assertThat(content).contains("redirectUri: \"{baseUrl}/login/oauth2/code/logto\""); + assertThat(content).contains("jwsAlgorithm: \"ES384\""); + assertThat(content).doesNotContain("configurationMetadata:"); + } + + @Test + void settingShouldContainDedicatedLogtoForm() throws IOException { + var content = readResource("extensions/setting.yaml"); + + assertThat(content).contains("name: logto-oauth2-setting"); + assertThat(content).contains("group: logtoOauth"); + assertThat(content).contains("name: endpoint"); + assertThat(content).contains("label: \"Endpoint\""); + assertThat(content).doesNotContain("auth.srku.cn"); + } + + @Test + void extensionResourcesShouldNotContainPrivateLogtoEndpointExample() throws IOException { + assertThat(readResource("extensions/auth-provider.yaml")).doesNotContain("auth.srku.cn"); + assertThat(readResource("extensions/client-registrations.yaml")).doesNotContain("auth.srku.cn"); + assertThat(readResource("extensions/setting.yaml")).doesNotContain("auth.srku.cn"); + } + + private static String readResource(String path) throws IOException { + try (var inputStream = new ClassPathResource(path).getInputStream()) { + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/src/test/java/run/halo/oauth/OauthClientRegistrationRepositoryTest.java b/src/test/java/run/halo/oauth/OauthClientRegistrationRepositoryTest.java index b03ad55..3d1adf6 100644 --- a/src/test/java/run/halo/oauth/OauthClientRegistrationRepositoryTest.java +++ b/src/test/java/run/halo/oauth/OauthClientRegistrationRepositoryTest.java @@ -8,6 +8,7 @@ import java.net.MalformedURLException; import java.net.URL; import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -111,4 +112,213 @@ void findByRegistrationId_withUnsupportedProvider_throwsProviderNotFoundExceptio .isInstanceOf(ProviderNotFoundException.class) .hasMessage("Unsupported OAuth2 provider: unsupported-provider"); } + + @Test + void findByRegistrationId_withSsoProvider_keepsExistingCustomOidcBehavior() { + AuthProvider authProvider = new AuthProvider(); + authProvider.setMetadata(new Metadata()); + authProvider.getMetadata().setName("sso"); + authProvider.setSpec(new AuthProvider.AuthProviderSpec()); + authProvider.getSpec().setDisplayName("SSO"); + authProvider.getSpec().setAuthenticationUrl("/oauth2/authorization/sso"); + authProvider.getSpec().setSettingRef(new AuthProvider.SettingRef()); + authProvider.getSpec().getSettingRef().setName("sso-oauth2-setting"); + authProvider.getSpec().getSettingRef().setGroup("ssoOauth"); + authProvider.getSpec().setConfigMapRef(new AuthProvider.ConfigMapRef()); + authProvider.getSpec().getConfigMapRef().setName("oauth2-sso-config"); + + when(client.fetch(eq(AuthProvider.class), eq("sso"))) + .thenReturn(Mono.just(authProvider)); + + ConfigMap systemConfig = new ConfigMap(); + systemConfig.setData(Map.of(SystemSetting.AuthProvider.GROUP, + """ + {"states":[{"name":"sso", "enabled":true}]}\ + """)); + when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG))) + .thenReturn(Mono.just(systemConfig)); + + Oauth2ClientRegistration registration = new Oauth2ClientRegistration(); + registration.setMetadata(new Metadata()); + registration.getMetadata().setName("sso"); + registration.setSpec(new Oauth2ClientRegistration.Oauth2ClientRegistrationSpec()); + registration.getSpec().setAuthorizationUri("https://example.com/login/oauth/authorize"); + registration.getSpec().setTokenUri("https://example.com/api/login/oauth/access_token"); + registration.getSpec().setUserInfoUri("https://example.com/api/user"); + registration.getSpec().setUserNameAttributeName("name"); + when(client.fetch(eq(Oauth2ClientRegistration.class), eq("sso"))) + .thenReturn(Mono.just(registration)); + + ConfigMap configMap = new ConfigMap(); + configMap.setData(Map.of("ssoOauth", + """ + { + "clientId":"sso-client-id", + "clientSecret":"sso-client-secret", + "authorizationUrl":"https://sso.example.com/oauth2/authorize", + "tokenUrl":"https://sso.example.com/oauth2/token", + "userInfoUrl":"https://sso.example.com/oauth2/userinfo", + "scopes":"openid profile", + "userNameAttribute":"preferred_username", + "issuerUri":"https://sso.example.com", + "jwkSetUri":"https://sso.example.com/oauth2/jwks" + }\ + """)); + when(client.fetch(eq(ConfigMap.class), eq("oauth2-sso-config"))) + .thenReturn(Mono.just(configMap)); + + StepVerifier.create(repository.findByRegistrationId("sso")) + .assertNext(clientRegistration -> { + assertThat(clientRegistration.getRegistrationId()).isEqualTo("sso"); + assertThat(clientRegistration.getClientId()).isEqualTo("sso-client-id"); + assertThat(clientRegistration.getClientSecret()).isEqualTo("sso-client-secret"); + assertThat(clientRegistration.getProviderDetails().getAuthorizationUri()) + .isEqualTo("https://sso.example.com/oauth2/authorize"); + assertThat(clientRegistration.getProviderDetails().getTokenUri()) + .isEqualTo("https://sso.example.com/oauth2/token"); + assertThat(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUri()) + .isEqualTo("https://sso.example.com/oauth2/userinfo"); + assertThat(clientRegistration.getProviderDetails().getIssuerUri()) + .isEqualTo("https://sso.example.com"); + assertThat(clientRegistration.getProviderDetails().getJwkSetUri()) + .isEqualTo("https://sso.example.com/oauth2/jwks"); + assertThat(clientRegistration.getScopes()) + .containsExactlyInAnyOrder("openid", "profile"); + }) + .expectComplete() + .verify(); + } + + @Test + void findByRegistrationId_withLogtoProvider_usesConfiguredOidcEndpoints() { + AuthProvider authProvider = new AuthProvider(); + authProvider.setMetadata(new Metadata()); + authProvider.getMetadata().setName("logto"); + authProvider.setSpec(new AuthProvider.AuthProviderSpec()); + authProvider.getSpec().setDisplayName("Logto"); + authProvider.getSpec().setAuthenticationUrl("/oauth2/authorization/logto"); + authProvider.getSpec().setSettingRef(new AuthProvider.SettingRef()); + authProvider.getSpec().getSettingRef().setName("logto-oauth2-setting"); + authProvider.getSpec().getSettingRef().setGroup("logtoOauth"); + authProvider.getSpec().setConfigMapRef(new AuthProvider.ConfigMapRef()); + authProvider.getSpec().getConfigMapRef().setName("oauth2-logto-config"); + + when(client.fetch(eq(AuthProvider.class), eq("logto"))) + .thenReturn(Mono.just(authProvider)); + + ConfigMap systemConfig = new ConfigMap(); + systemConfig.setData(Map.of(SystemSetting.AuthProvider.GROUP, + """ + {"states":[{"name":"logto", "enabled":true}]}\ + """)); + when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG))) + .thenReturn(Mono.just(systemConfig)); + + Oauth2ClientRegistration registration = new Oauth2ClientRegistration(); + registration.setMetadata(new Metadata()); + registration.getMetadata().setName("logto"); + registration.setSpec(new Oauth2ClientRegistration.Oauth2ClientRegistrationSpec()); + registration.getSpec().setAuthorizationUri("https://example.logto.app/oidc/auth"); + registration.getSpec().setTokenUri("https://example.logto.app/oidc/token"); + registration.getSpec().setUserInfoUri("https://example.logto.app/oidc/me"); + registration.getSpec().setUserNameAttributeName("sub"); + registration.getSpec().setScopes(Set.of("openid", "profile", "email")); + registration.getSpec().setJwsAlgorithm("ES384"); + when(client.fetch(eq(Oauth2ClientRegistration.class), eq("logto"))) + .thenReturn(Mono.just(registration)); + + ConfigMap configMap = new ConfigMap(); + configMap.setData(Map.of("logtoOauth", + """ + { + "clientId":"logto-client-id", + "clientSecret":"logto-client-secret", + "endpoint":"https://auth.example.com" + }\ + """)); + when(client.fetch(eq(ConfigMap.class), eq("oauth2-logto-config"))) + .thenReturn(Mono.just(configMap)); + + StepVerifier.create(repository.findByRegistrationId("logto")) + .assertNext(clientRegistration -> { + assertThat(clientRegistration.getRegistrationId()).isEqualTo("logto"); + assertThat(clientRegistration.getClientId()).isEqualTo("logto-client-id"); + assertThat(clientRegistration.getClientSecret()).isEqualTo("logto-client-secret"); + assertThat(clientRegistration.getProviderDetails().getAuthorizationUri()) + .isEqualTo("https://auth.example.com/oidc/auth"); + assertThat(clientRegistration.getProviderDetails().getTokenUri()) + .isEqualTo("https://auth.example.com/oidc/token"); + assertThat(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUri()) + .isEqualTo("https://auth.example.com/oidc/me"); + assertThat(clientRegistration.getProviderDetails().getIssuerUri()) + .isEqualTo("https://auth.example.com/oidc"); + assertThat(clientRegistration.getProviderDetails().getJwkSetUri()) + .isEqualTo("https://auth.example.com/oidc/jwks"); + assertThat(clientRegistration.getProviderDetails().getConfigurationMetadata()) + .containsEntry("jwsAlgorithm", "ES384"); + assertThat(clientRegistration.getScopes()) + .containsExactlyInAnyOrder("openid", "profile", "email"); + }) + .expectComplete() + .verify(); + } + + @Test + void findByRegistrationId_withLogtoIssuerEndpoint_shouldNotDuplicateOidcSegment() { + AuthProvider authProvider = new AuthProvider(); + authProvider.setMetadata(new Metadata()); + authProvider.getMetadata().setName("logto"); + authProvider.setSpec(new AuthProvider.AuthProviderSpec()); + authProvider.getSpec().setSettingRef(new AuthProvider.SettingRef()); + authProvider.getSpec().getSettingRef().setName("logto-oauth2-setting"); + authProvider.getSpec().getSettingRef().setGroup("logtoOauth"); + authProvider.getSpec().setConfigMapRef(new AuthProvider.ConfigMapRef()); + authProvider.getSpec().getConfigMapRef().setName("oauth2-logto-config"); + + when(client.fetch(eq(AuthProvider.class), eq("logto"))) + .thenReturn(Mono.just(authProvider)); + + ConfigMap systemConfig = new ConfigMap(); + systemConfig.setData(Map.of(SystemSetting.AuthProvider.GROUP, + """ + {"states":[{"name":"logto", "enabled":true}]}\ + """)); + when(client.fetch(eq(ConfigMap.class), eq(SystemSetting.SYSTEM_CONFIG))) + .thenReturn(Mono.just(systemConfig)); + + Oauth2ClientRegistration registration = new Oauth2ClientRegistration(); + registration.setMetadata(new Metadata()); + registration.getMetadata().setName("logto"); + registration.setSpec(new Oauth2ClientRegistration.Oauth2ClientRegistrationSpec()); + registration.getSpec().setAuthorizationUri("https://example.logto.app/oidc/auth"); + registration.getSpec().setTokenUri("https://example.logto.app/oidc/token"); + registration.getSpec().setUserInfoUri("https://example.logto.app/oidc/me"); + registration.getSpec().setUserNameAttributeName("sub"); + registration.getSpec().setScopes(Set.of("openid", "profile", "email")); + registration.getSpec().setJwsAlgorithm("ES384"); + when(client.fetch(eq(Oauth2ClientRegistration.class), eq("logto"))) + .thenReturn(Mono.just(registration)); + + ConfigMap configMap = new ConfigMap(); + configMap.setData(Map.of("logtoOauth", + """ + { + "clientId":"logto-client-id", + "clientSecret":"logto-client-secret", + "endpoint":"https://auth.example.com/oidc/" + }\ + """)); + when(client.fetch(eq(ConfigMap.class), eq("oauth2-logto-config"))) + .thenReturn(Mono.just(configMap)); + + StepVerifier.create(repository.findByRegistrationId("logto")) + .assertNext(clientRegistration -> { + assertThat(clientRegistration.getProviderDetails().getIssuerUri()) + .isEqualTo("https://auth.example.com/oidc"); + assertThat(clientRegistration.getProviderDetails().getAuthorizationUri()) + .isEqualTo("https://auth.example.com/oidc/auth"); + }) + .expectComplete() + .verify(); + } }