Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 28 additions & 19 deletions src/main/java/run/halo/oauth/HaloOAuth2AuthenticationWebFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,6 +43,7 @@
@Component
public class HaloOAuth2AuthenticationWebFilter implements AuthenticationSecurityWebFilter {

static final String JWS_ALGORITHM_METADATA_KEY = "jwsAlgorithm";

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metadata key "jwsAlgorithm" is defined separately here and in OauthClientRegistrationRepository. Duplicating the literal/constant in multiple places risks future drift (one side changes and the other doesn’t), which would silently disable the override; consider centralizing the key in a shared constant (or referencing one class’s constant) to keep writer/reader aligned.

Suggested change
static final String JWS_ALGORITHM_METADATA_KEY = "jwsAlgorithm";
static final String JWS_ALGORITHM_METADATA_KEY =
OauthClientRegistrationRepository.JWS_ALGORITHM_METADATA_KEY;

Copilot uses AI. Check for mistakes.
private final WebFilter delegate;

public HaloOAuth2AuthenticationWebFilter(Oauth2LoginConfiguration configuration,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -136,4 +122,27 @@ public Mono<Void> 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);

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveJwsAlgorithm calls SignatureAlgorithm.from(jwsAlgorithm) for the explicitly configured value without guarding against invalid/unknown algorithms. If a user misconfigures jwsAlgorithm, this will throw IllegalArgumentException and can break the authentication flow; consider catching it (similar to the metadata path) and either fallback to provider metadata/default or surface a clearer configuration error.

Suggested change
return SignatureAlgorithm.from(jwsAlgorithm);
try {
return SignatureAlgorithm.from(jwsAlgorithm);
} catch (IllegalArgumentException ex) {
log.warn(
"Invalid JWS algorithm '{}' configured for client '{}' in metadata key '{}'; "
+ "falling back to provider metadata/default.",
jwsAlgorithm,
clientRegistration.getRegistrationId(),
JWS_ALGORITHM_METADATA_KEY,
ex
);
}

Copilot uses AI. Check for mistakes.
}
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;
}
}
3 changes: 1 addition & 2 deletions src/main/java/run/halo/oauth/Oauth2ClientRegistration.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,7 +53,7 @@ public static class Oauth2ClientRegistrationSpec {

private String issuerUri;

private Map<String, Object> configurationMetadata;
private String jwsAlgorithm;

private String clientName;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -74,7 +76,20 @@ private Mono<ClientRegistration> 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);
}
}
Comment on lines +80 to +87

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOGTO_SETTING_GROUP handling attempts to fall back to ADVANCED_OIDC_SETTING_GROUP data from the same provider ConfigMap. However, the shipped extension resources use different ConfigMaps for sso (oauth2-sso-config) and logto (oauth2-logto-config), so this fallback won’t migrate existing SSO-based Logto settings unless users manually copy the old group key into the new ConfigMap. If migration is intended, consider fetching from the SSO ConfigMap explicitly; otherwise removing this branch would avoid confusing, hard-to-reason-about behavior.

Suggested change
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);
}
}

Copilot uses AI. Check for mistakes.
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);
Expand Down Expand Up @@ -108,6 +123,27 @@ private Mono<ClientRegistration> GenericClientRegistration(GenericClientConf gen
);
}

private Mono<ClientRegistration> 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<ClientRegistration> SsoClientRegistration(SsoClientConf ssoClientConf,
AuthProvider authProvider) {
String registrationId = authProvider.getMetadata().getName();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<String, Object> 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<Set<String>> fetchEnabledProviders() {
return client.fetch(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)
.map(configMap -> {
Expand Down
24 changes: 23 additions & 1 deletion src/main/resources/extensions/auth-provider.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,26 @@ spec:
name: sso-oauth2-setting
group: ssoOauth
configMapRef:
name: oauth2-sso-config
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
20 changes: 20 additions & 0 deletions src/main/resources/extensions/client-registrations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
24 changes: 24 additions & 0 deletions src/main/resources/extensions/setting.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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。"
9 changes: 9 additions & 0 deletions src/main/resources/static/logto.svg
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,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);
}
Comment on lines +65 to +76

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test uses reflection to invoke HaloOAuth2AuthenticationWebFilter.resolveJwsAlgorithm, but the method is package-visible and the test is in the same run.halo.oauth package, so it can be called directly. Dropping reflection would make the test simpler and less brittle (no setAccessible(true) / NoSuchMethodException handling needed).

Copilot uses AI. Check for mistakes.
throw new IllegalStateException("Unreachable");
}
}
Loading
Loading