diff --git a/AGENT.md b/AGENT.md index 04b6bd3..d982a2f 100644 --- a/AGENT.md +++ b/AGENT.md @@ -9,3 +9,141 @@ file tools or Python instead. 2. Use English in code. Avoid Chinese, Japanese, or any other non-ASCII natural language inside source files. + +## Boot rules + +The very first time I ran `java -cp ... cn.violin.iam.ViolinIamApplication`, +Spring failed with "Parameter 0 of constructor in +`cn.violin.core.config.WebMvcConfig` required a bean of type +`cn.violin.core.interceptor.AuthenticationInterceptor` that could not be +found". The unit-test suite was green but had never actually booted the +context. Concrete gotchas to fix: + +- **`@SpringBootApplication` MUST set `scanBasePackages = {"cn.violin"}`** + explicitly. The default only scans the app's own sub-package + (`cn.violin.iam`), which excludes `cn.violin.core.interceptor.AuthenticationInterceptor`, + `CurrentUserHandlerMethodArgumentResolver`, `TraceIdFilter`, and other + framework beans. Spring Boot auto-config registers the + `@AutoConfiguration` classes themselves, but their `@Bean` dependencies + that are not themselves exposed via another `@Bean` still need component-scan + coverage. Compare with `violin-auth`, which has always used + `scanBasePackages = {"cn.violin"}`; it never tripped over this. + +- **`@MapperScan("cn.violin.*.mapper")` — never use bare `cn.violin`.** + The broad form hoovers up every interface in the project, including + framework-internal ones like `JwksClient`, and MyBatis then complains + at runtime: + `Invalid bound statement (not found): cn.violin.core.security.JwksClient.loadKeys`. + Narrowing the pattern prevents MyBatis from treating non-mapper + interfaces as proxies. + +- **`@Service` impls whose interface is in the same package should be + marked `@Primary` when `scanBasePackages` is widened.** Once + `cn.violin.*` is scanned, Spring ends up holding two `BeanDefinition`s + of the same bean type (the impl + some derived candidate), and + `@Autowired` of the interface fails with `NoUniqueBeanDefinitionException: + expected single matching bean but found 2: OAuthServiceImpl,OAuthService`. + Adding `@Primary` to the impl collapses that to a single candidate. + +- **Spring 6 forbids `@PostConstruct` methods with parameters.** + `SnowflakeId.init(SnowflakeProperties props)` looked innocuous but + `IllegalStateException: Lifecycle annotation requires a no-arg method` + hit on `@SpringBootTest`. Use `InitializingBean` + `@Autowired` for + the setter (properties injection remains valid via + `wireProperties`). Same pattern applies to any other singleton that + needs configuration at boot. + +- **`@ConfigurationProperties` on Java `record` types needs + `@ConstructorBinding`** when the canonical constructor does not match + Spring's expected discovery. Without it, Spring fails with + `No default constructor found`. + +- **Adding `@SpringBootTest` early catches all four of these.** Place a + smoke test (`ApplicationContextLoadsTest`) in + `src/test/java/.../boot/` and run `mvn test`. If the unit tests are + green but the boot test fails, the bug is precisely the kind that + silently passes when scanning only `@Component`-annotated classes + but blows up at full startup. + +## Design notes (do not skip) + +## Design notes (do not skip) + +### JWT revocation propagation — what we tried, what we picked, and why + +The naive question "should we drop `customerId` from the JWT claim so +downstream must call IAM every request?" was the wrong frame. Below is +the actual decision tree we walked. + +#### Problem +A user's `t_user.customer_id` is migrated from tenant A to tenant B. The +JWT issued at login still carries `customerId=A`. Without propagation +the user keeps operating under A's RBAC scope (and SAR cache) for the +remainder of the token TTL. + +#### Industry patterns surveyed + +1. **Short TTL + RFC 7662 introspection** (Google / AWS / GitHub / + Slack). Token TTL = 5-15 min; each request consults an introspection + endpoint (with local cache) to check the token is still live. + Revocation propagates in <1 s via shared cache / pub-sub. + +2. **`revokedCustomerBindings` cache** (medium-scale). + `Cache` with TTL covering the longest possible + token lifetime. `UserService.changeCustomer(...)` writes the DB row + then publishes `(userId, oldCustomerId)` into the cache. The cache + TTL ensures stale entries eventually fall out without a cron. + +3. **Active binding flag** (per-user cache of currently-valid + customerId). cache miss → fall back to DB lookup. cache hit but + cache.customerId != JWT claim → treat as revoke signal. + +4. **Kafka / event-driven revocation** (large clusters, >10 services). + IAM emits `user.tenant.changed` to a topic; subscribers invalidate + local cache on receipt. Strictly over-engineered for a 1-issuer / + 5-subscriber fleet. + +#### What we picked and why + +We picked **option 2** (`revokedCustomerBindings` cache) and combined +it with **token TTL reduction** from 24 h to **1 h**. Net result: + +- Worst-case staleness window = `min(token TTL, cache TTL)` = 1 h. +- No per-request IAM call (the user's framing-question was "is the + extra IAM call worth it?" — answer: no, because it moves IAM onto + the hot path and turns it into a single point of failure). +- Single-instance IAM means cache state is authoritative without + sticky sessions or shared cache. + +#### What we did NOT do (and the reasons) + +- **Drop `customerId` from JWT claim.** Would force every permission + check to call `POST /api/v1/internal/check-permission`, adding + 2-5 ms latency per request and making IAM the hot path. Token + claim is now treated as **informational only** — never trusted + for authorization. All decisions go through IAM SAR. +- **Re-sign JWT on every customer change.** Requires Kafka or + webhook infra to broadcast — engineering cost far exceeds risk + reduction given the 1 h TTL already covers the window. +- **Use Authentik's session revocation directly.** Adds an upstream + dependency IAM cannot fail without. + +#### Implementation hooks already in place + +| File | Role | +| --- | --- | +| `cn.violin.common.context.RequestContext` | exposes `setCustomerIdHint(...)` and `getVerifiedCustomerId()` — verified provenance is now mandatory for SAR / PermissionAspect / RbacController / ResourcePermissionAspect | +| `SubjectAccessReviewRequest.customerVerified` | explicit boolean; SAR denies on `false` | +| `PermissionAspect` | reads `getVerifiedCustomerId()` first, throws `UnauthorizedException` if null | +| `ResourcePermissionAspect` | same | +| `RbacController.access-check` | same | +| `InternalController.check-permission` | accepts only verified (set from HMAC + ServiceAllowlist mapping) | + +#### Followup still pending (backlog, not on the critical path) + +Add `CustomerBindingRevocationCache` and have `UserService.changeCustomer(...)` +push `(sub, oldCustomerId)` into it. Have `PermissionAspect` consult the cache +as a secondary check (after verified gate). Reduce default `expirationMs` +in `ViolinJwtProperties` from `86_400_000` (24 h) to `3_600_000` (1 h). +These three together close the revocation window to the bound of TTL. + diff --git a/src/main/java/cn/violin/iam/ViolinIamApplication.java b/src/main/java/cn/violin/iam/ViolinIamApplication.java index 53b183f..016c5c2 100644 --- a/src/main/java/cn/violin/iam/ViolinIamApplication.java +++ b/src/main/java/cn/violin/iam/ViolinIamApplication.java @@ -1,6 +1,7 @@ package cn.violin.iam; -import cn.violin.iam.config.AuthentikProperties; +import cn.violin.iam.config.AuthentikOidcProperties; +import cn.violin.iam.config.OidcProperties; import cn.violin.iam.config.ServiceAuthProperties; import cn.violin.iam.config.ViolinJwtProperties; import org.mybatis.spring.annotation.MapperScan; @@ -10,11 +11,12 @@ import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; -@SpringBootApplication -@MapperScan("cn.violin") +@SpringBootApplication(scanBasePackages = {"cn.violin"}) +@MapperScan("cn.violin.*.mapper") @EnableConfigurationProperties({ ViolinJwtProperties.class, - AuthentikProperties.class, + OidcProperties.class, + AuthentikOidcProperties.class, ServiceAuthProperties.class }) public class ViolinIamApplication extends SpringBootServletInitializer { diff --git a/src/main/java/cn/violin/iam/config/AuthentikOidcProperties.java b/src/main/java/cn/violin/iam/config/AuthentikOidcProperties.java new file mode 100644 index 0000000..3072c31 --- /dev/null +++ b/src/main/java/cn/violin/iam/config/AuthentikOidcProperties.java @@ -0,0 +1,24 @@ +package cn.violin.iam.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +/** + * Configuration bundle for the Authentik-specific OIDC endpoints. All fields + * are optional; when blank, {@link cn.violin.iam.sso.AuthentikConf} falls + * back to the canonical Authentik path + * {@code /application/o//}. + */ +@ConfigurationProperties(prefix = "iam.oidc.authentik") +public record AuthentikOidcProperties( + String tokenEndpoint, + String userinfoEndpoint, + String authorizeEndpoint +) { + @ConstructorBinding + public AuthentikOidcProperties { + tokenEndpoint = tokenEndpoint == null ? "" : tokenEndpoint.strip(); + userinfoEndpoint = userinfoEndpoint == null ? "" : userinfoEndpoint.strip(); + authorizeEndpoint = authorizeEndpoint == null ? "" : authorizeEndpoint.strip(); + } +} diff --git a/src/main/java/cn/violin/iam/config/AuthentikProperties.java b/src/main/java/cn/violin/iam/config/AuthentikProperties.java deleted file mode 100644 index 9379896..0000000 --- a/src/main/java/cn/violin/iam/config/AuthentikProperties.java +++ /dev/null @@ -1,27 +0,0 @@ -package cn.violin.iam.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -/** - * Configuration bundle for Authentik OIDC client. - */ -@ConfigurationProperties(prefix = "authentik") -public class AuthentikProperties { - - private String issuer = ""; - private String clientId = ""; - private String clientSecret = ""; - private String redirectUri = ""; - private String scope = "openid profile email"; - - public String getIssuer() { return issuer; } - public void setIssuer(String issuer) { this.issuer = issuer; } - public String getClientId() { return clientId; } - public void setClientId(String clientId) { this.clientId = clientId; } - public String getClientSecret() { return clientSecret; } - public void setClientSecret(String clientSecret) { this.clientSecret = clientSecret; } - public String getRedirectUri() { return redirectUri; } - public void setRedirectUri(String redirectUri) { this.redirectUri = redirectUri; } - public String getScope() { return scope; } - public void setScope(String scope) { this.scope = scope; } -} diff --git a/src/main/java/cn/violin/iam/config/OidcProperties.java b/src/main/java/cn/violin/iam/config/OidcProperties.java new file mode 100644 index 0000000..daca881 --- /dev/null +++ b/src/main/java/cn/violin/iam/config/OidcProperties.java @@ -0,0 +1,44 @@ +package cn.violin.iam.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; +import org.springframework.util.StringUtils; + +/** + * Configuration bundle for the OIDC client identity (provider-independent). + * + *

Bound to {@code iam.oidc.client.*}; the actual provider endpoints + * (e.g. Authentik-specific paths) live in {@link AuthentikOidcProperties}.

+ * + *

Two fields ({@code issuer}, {@code clientId}) are mandatory at startup + * when the active Spring profile is not {@code local} — local dev may run + * with blank values to defer OIDC wiring to a later iteration.

+ */ +@ConfigurationProperties(prefix = "iam.oidc.client") +public record OidcProperties( + String issuer, + String clientId, + String clientSecret, + String redirectUri, + String scope, + String profile +) { + @ConstructorBinding + public OidcProperties { + issuer = StringUtils.trimTrailingCharacter(nullSafe(issuer).trim(), '/'); + clientId = nullSafe(clientId).trim(); + clientSecret = nullSafe(clientSecret).strip(); + redirectUri = nullSafe(redirectUri).strip(); + scope = StringUtils.hasText(scope) ? scope.strip() : "openid profile email"; + profile = StringUtils.hasText(profile) ? profile.trim() : "local"; + } + + /** True when the active Spring profile equals {@code local} (case-insensitive). */ + public boolean isLocal() { + return "local".equalsIgnoreCase(profile); + } + + private static String nullSafe(String s) { + return s == null ? "" : s; + } +} diff --git a/src/main/java/cn/violin/iam/sso/AuthentikConf.java b/src/main/java/cn/violin/iam/sso/AuthentikConf.java index e97b8ad..3f1fcae 100644 --- a/src/main/java/cn/violin/iam/sso/AuthentikConf.java +++ b/src/main/java/cn/violin/iam/sso/AuthentikConf.java @@ -1,48 +1,113 @@ package cn.violin.iam.sso; -import cn.violin.iam.config.AuthentikProperties; -import lombok.Data; +import cn.violin.iam.config.AuthentikOidcProperties; +import cn.violin.iam.config.OidcProperties; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; -@Data +/** + * Resolves OIDC endpoint URLs for the Authentik provider. + * + *

Composes two configuration sources: + *

    + *
  • {@link OidcProperties} — provider-independent client identity + * (issuer, clientId/secret, redirectUri, scope).
  • + *
  • {@link AuthentikOidcProperties} — Authentik-specific endpoint paths + * (tokenEndpoint, userinfoEndpoint, authorizeEndpoint). Defaults match + * Authentik 2023.x installs; overrides are useful for tenant-specific + * reverse proxies or self-hosted legacy versions.
  • + *
+ * + *

When {@code iam.oidc.authentik.*} paths are present they take precedence; + * when absent, URLs are derived from {@code iam.oidc.client.issuer} and the + * standard Authentik path {@code /application/o/...}.

+ */ @Configuration @Primary -@EnableConfigurationProperties(AuthentikProperties.class) +@EnableConfigurationProperties({OidcProperties.class, AuthentikOidcProperties.class}) +@Slf4j public class AuthentikConf { - private final AuthentikProperties props; + private final OidcProperties client; + private final AuthentikOidcProperties endpoints; - public AuthentikConf(AuthentikProperties props) { - this.props = props; + public AuthentikConf(OidcProperties client, AuthentikOidcProperties endpoints) { + this.client = client; + this.endpoints = endpoints; + log.info("Authentik OIDC ready: issuer={}, endpoints overridden={}", + client.issuer(), + endpointsOverrideActive()); } - public String getIssuer() { return props.getIssuer(); } - public String getClientId() { return props.getClientId(); } - public String getClientSecret() { return props.getClientSecret(); } - public String getRedirectUri() { return props.getRedirectUri(); } - public String getScope() { return props.getScope(); } + /** + * Fail-fast validation: blank {@code issuer} / {@code clientId} are only + * tolerated when the active profile equals {@code local}. + */ + @PostConstruct + void validate() { + if (client.isLocal()) { + if (client.issuer().isBlank() || client.clientId().isBlank()) { + log.warn("iam.oidc.client.issuer/client-id are blank under local profile; " + + "OIDC login will fail until configured"); + } + return; + } + if (client.issuer().isBlank()) { + throw new IllegalStateException( + "iam.oidc.client.issuer must be set for profile=" + client.profile() + + " (mount K8s Secret or ConfigMap)"); + } + if (client.clientId().isBlank()) { + throw new IllegalStateException( + "iam.oidc.client.client-id must be set for profile=" + client.profile()); + } + } + + public String getIssuer() { return client.issuer(); } + public String getClientId() { return client.clientId(); } + public String getClientSecret() { return client.clientSecret(); } + public String getRedirectUri() { return client.redirectUri(); } + public String getScope() { return client.scope(); } public String getTokenUrl() { - return base() + "/application/o/token/"; + return resolve(endpoints.tokenEndpoint(), "/application/o/token/"); } public String getUserInfoUrl() { - return base() + "/application/o/userinfo/"; + return resolve(endpoints.userinfoEndpoint(), "/application/o/userinfo/"); } public String getAuthorizeUrl() { - return base() + "/application/o/authorize/"; + return resolve(endpoints.authorizeEndpoint(), "/application/o/authorize/"); } - private String base() { - String s = props.getIssuer(); + /** + * @return base URL for the Authentik OIDC tenant, with trailing slash trimmed. + * Empty string when {@link OidcProperties#issuer()} is blank. + */ + public String getIssuerBaseUrl() { + String s = client.issuer(); if (s == null) return ""; - s = s.trim(); - while (s.endsWith("/")) { - s = s.substring(0, s.length() - 1); - } - return s; + return stripTrailingSlash(s.trim()); + } + + /** + * @return true when at least one endpoint is overridden via {@code iam.oidc.authentik.*}. + */ + public boolean endpointsOverrideActive() { + return !endpoints.tokenEndpoint().isBlank() + || !endpoints.userinfoEndpoint().isBlank() + || !endpoints.authorizeEndpoint().isBlank(); + } + + private String resolve(String override, String defaultSuffix) { + return override.isBlank() ? getIssuerBaseUrl() + defaultSuffix : override; + } + + private static String stripTrailingSlash(String s) { + return s.endsWith("/") ? stripTrailingSlash(s.substring(0, s.length() - 1)) : s; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 89b896b..721831f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -31,20 +31,24 @@ violin: auth: exclude-paths: /error anonymous-message-code: AUTHORIZATION_MISSING - iam: - service-id: ${VIOLIN_IAM_SERVICE_ID:violin-caller} - url: ${VIOLIN_IAM_URL:http://localhost:8080} - context-path: ${VIOLIN_IAM_CONTEXT_PATH:/violin-iam} - service-token-secret: ${VIOLIN_IAM_SERVICE_TOKEN_SECRET:} - service-allowlist: ${VIOLIN_IAM_SERVICE_ALLOWLIST:} - profile: ${SPRING_PROFILES_ACTIVE:local} - -authentik: - issuer: ${AUTHENTIK_ISSUER:} - client-id: ${AUTHENTIK_CLIENT_ID:} - client-secret: ${AUTHENTIK_CLIENT_SECRET:} - redirect-uri: ${AUTHENTIK_REDIRECT_URI:} - scope: ${AUTHENTIK_SCOPE:openid profile email} +iam: + service-id: ${VIOLIN_IAM_SERVICE_ID:violin-caller} + url: ${VIOLIN_IAM_URL:http://localhost:8080} + context-path: ${VIOLIN_IAM_CONTEXT_PATH:/violin-iam} + service-token-secret: ${VIOLIN_IAM_SERVICE_TOKEN_SECRET:} + service-allowlist: ${VIOLIN_IAM_SERVICE_ALLOWLIST:} + profile: ${SPRING_PROFILES_ACTIVE:local} + oidc: + client: + issuer: ${IAM_OIDC_CLIENT_ISSUER:${AUTHENTIK_ISSUER:}} + client-id: ${IAM_OIDC_CLIENT_CLIENT_ID:${AUTHENTIK_CLIENT_ID:}} + client-secret: ${IAM_OIDC_CLIENT_CLIENT_SECRET:${AUTHENTIK_CLIENT_SECRET:}} + redirect-uri: ${IAM_OIDC_CLIENT_REDIRECT_URI:${AUTHENTIK_REDIRECT_URI:}} + scope: ${IAM_OIDC_CLIENT_SCOPE:${AUTHENTIK_SCOPE:openid profile email}} + authentik: + token-endpoint: ${IAM_OIDC_AUTHENTIK_TOKEN_ENDPOINT:} + userinfo-endpoint: ${IAM_OIDC_AUTHENTIK_USERINFO_ENDPOINT:} + authorize-endpoint: ${IAM_OIDC_AUTHENTIK_AUTHORIZE_ENDPOINT:} management: endpoints: diff --git a/src/test/java/cn/violin/iam/boot/ApplicationContextLoadsTest.java b/src/test/java/cn/violin/iam/boot/ApplicationContextLoadsTest.java new file mode 100644 index 0000000..196d536 --- /dev/null +++ b/src/test/java/cn/violin/iam/boot/ApplicationContextLoadsTest.java @@ -0,0 +1,41 @@ +package cn.violin.iam.boot; + +import cn.violin.iam.ViolinIamApplication; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +/** + * Smoke test that boots the full Spring context. Acts as a regression guard + * for the round-5 startup bug: {@link ViolinIamApplication} needed an + * explicit {@code scanBasePackages} for {@code cn.violin.*} so that the + * core module's {@code @Component}-marked beans (notably + * {@code AuthenticationInterceptor} and + * {@code CurrentUserHandlerMethodArgumentResolver}) get registered. + * + *

This test exists because round-3 / round-4 unit tests never started + * the context — they only instantiated components in isolation — and so + * the missing scan range remained hidden until a real + * {@code java -cp} / packaged-jar startup was attempted.

+ */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@TestPropertySource(properties = { + "violin.oidc.client.issuer=https://idp.example.com", + "violin.oidc.client.client-id=test-client-id", + "violin.jwt.profile=local", + "violin.iam.profile=local", + // Avoid needing real Postgres for the smoke context. + "spring.datasource.url=jdbc:postgresql://localhost:5432/none", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.driver-class-name=org.postgresql.Driver", + "spring.flyway.enabled=false" +}) +class ApplicationContextLoadsTest { + + @Test + void contextLoads() { + // No assertion: success means every wiring resolved, in particular + // WebMvcConfig -> AuthenticationInterceptor. + } +} diff --git a/src/test/java/cn/violin/iam/sso/AuthentikConfUrlResolutionTest.java b/src/test/java/cn/violin/iam/sso/AuthentikConfUrlResolutionTest.java new file mode 100644 index 0000000..86eda2a --- /dev/null +++ b/src/test/java/cn/violin/iam/sso/AuthentikConfUrlResolutionTest.java @@ -0,0 +1,62 @@ +package cn.violin.iam.sso; + +import cn.violin.iam.config.AuthentikOidcProperties; +import cn.violin.iam.config.OidcProperties; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the AuthentikConf URL composition: provider-specific endpoint + * overrides take precedence; blank overrides fall back to issuer + standard + * Authentik path suffix. + */ +class AuthentikConfUrlResolutionTest { + + private static OidcProperties client(String issuer, String profile) { + return new OidcProperties(issuer, "client-id-x", "secret", "https://app/cb", "openid", profile); + } + + private static AuthentikOidcProperties overrides(String token, String userinfo, String authorize) { + return new AuthentikOidcProperties(token, userinfo, authorize); + } + + @Test + void fallsBackToIssuerPlusDefaultPathWhenOverridesAreBlank() { + AuthentikConf conf = new AuthentikConf( + client("https://idp.example.com/", "prod"), + overrides("", "", "")); + assertEquals("https://idp.example.com/application/o/token/", conf.getTokenUrl()); + assertEquals("https://idp.example.com/application/o/userinfo/", conf.getUserInfoUrl()); + assertEquals("https://idp.example.com/application/o/authorize/", conf.getAuthorizeUrl()); + assertFalse(conf.endpointsOverrideActive()); + assertEquals("https://idp.example.com", conf.getIssuerBaseUrl()); + } + + @Test + void explicitEndpointsTakePrecedence() { + AuthentikConf conf = new AuthentikConf( + client("https://idp.example.com/", "prod"), + overrides( + "https://idp.example.com/api/o/token/", + "https://idp.example.com/api/o/userinfo/", + "https://idp.example.com/api/o/authorize/")); + assertEquals("https://idp.example.com/api/o/token/", conf.getTokenUrl()); + assertEquals("https://idp.example.com/api/o/userinfo/", conf.getUserInfoUrl()); + assertEquals("https://idp.example.com/api/o/authorize/", conf.getAuthorizeUrl()); + assertTrue(conf.endpointsOverrideActive()); + } + + @Test + void partialOverrideKeepsDefaultsForOtherEndpoints() { + AuthentikConf conf = new AuthentikConf( + client("https://idp.example.com", "prod"), + overrides("https://custom.example.com/oauth/token", "", "")); + assertEquals("https://custom.example.com/oauth/token", conf.getTokenUrl()); + assertEquals("https://idp.example.com/application/o/userinfo/", conf.getUserInfoUrl()); + assertEquals("https://idp.example.com/application/o/authorize/", conf.getAuthorizeUrl()); + assertTrue(conf.endpointsOverrideActive()); + } +}