refactor: apply ConfigurationProperties
This commit is contained in:
@@ -9,3 +9,141 @@
|
|||||||
file tools or Python instead.
|
file tools or Python instead.
|
||||||
2. Use English in code. Avoid Chinese, Japanese, or any other non-ASCII
|
2. Use English in code. Avoid Chinese, Japanese, or any other non-ASCII
|
||||||
natural language inside source files.
|
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<userId, oldCustomerId>` 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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package cn.violin.iam;
|
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.ServiceAuthProperties;
|
||||||
import cn.violin.iam.config.ViolinJwtProperties;
|
import cn.violin.iam.config.ViolinJwtProperties;
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
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.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
|
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication(scanBasePackages = {"cn.violin"})
|
||||||
@MapperScan("cn.violin")
|
@MapperScan("cn.violin.*.mapper")
|
||||||
@EnableConfigurationProperties({
|
@EnableConfigurationProperties({
|
||||||
ViolinJwtProperties.class,
|
ViolinJwtProperties.class,
|
||||||
AuthentikProperties.class,
|
OidcProperties.class,
|
||||||
|
AuthentikOidcProperties.class,
|
||||||
ServiceAuthProperties.class
|
ServiceAuthProperties.class
|
||||||
})
|
})
|
||||||
public class ViolinIamApplication extends SpringBootServletInitializer {
|
public class ViolinIamApplication extends SpringBootServletInitializer {
|
||||||
|
|||||||
@@ -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 <issuer>/application/o/<endpoint>/}.
|
||||||
|
*/
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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).
|
||||||
|
*
|
||||||
|
* <p>Bound to {@code iam.oidc.client.*}; the actual provider endpoints
|
||||||
|
* (e.g. Authentik-specific paths) live in {@link AuthentikOidcProperties}.</p>
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*/
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +1,113 @@
|
|||||||
package cn.violin.iam.sso;
|
package cn.violin.iam.sso;
|
||||||
|
|
||||||
import cn.violin.iam.config.AuthentikProperties;
|
import cn.violin.iam.config.AuthentikOidcProperties;
|
||||||
import lombok.Data;
|
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.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.context.annotation.Primary;
|
import org.springframework.context.annotation.Primary;
|
||||||
|
|
||||||
@Data
|
/**
|
||||||
|
* Resolves OIDC endpoint URLs for the Authentik provider.
|
||||||
|
*
|
||||||
|
* <p>Composes two configuration sources:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link OidcProperties} — provider-independent client identity
|
||||||
|
* (issuer, clientId/secret, redirectUri, scope).</li>
|
||||||
|
* <li>{@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.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>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/...}.</p>
|
||||||
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@Primary
|
@Primary
|
||||||
@EnableConfigurationProperties(AuthentikProperties.class)
|
@EnableConfigurationProperties({OidcProperties.class, AuthentikOidcProperties.class})
|
||||||
|
@Slf4j
|
||||||
public class AuthentikConf {
|
public class AuthentikConf {
|
||||||
|
|
||||||
private final AuthentikProperties props;
|
private final OidcProperties client;
|
||||||
|
private final AuthentikOidcProperties endpoints;
|
||||||
|
|
||||||
public AuthentikConf(AuthentikProperties props) {
|
public AuthentikConf(OidcProperties client, AuthentikOidcProperties endpoints) {
|
||||||
this.props = props;
|
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(); }
|
* Fail-fast validation: blank {@code issuer} / {@code clientId} are only
|
||||||
public String getClientSecret() { return props.getClientSecret(); }
|
* tolerated when the active profile equals {@code local}.
|
||||||
public String getRedirectUri() { return props.getRedirectUri(); }
|
*/
|
||||||
public String getScope() { return props.getScope(); }
|
@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() {
|
public String getTokenUrl() {
|
||||||
return base() + "/application/o/token/";
|
return resolve(endpoints.tokenEndpoint(), "/application/o/token/");
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getUserInfoUrl() {
|
public String getUserInfoUrl() {
|
||||||
return base() + "/application/o/userinfo/";
|
return resolve(endpoints.userinfoEndpoint(), "/application/o/userinfo/");
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getAuthorizeUrl() {
|
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 "";
|
if (s == null) return "";
|
||||||
s = s.trim();
|
return stripTrailingSlash(s.trim());
|
||||||
while (s.endsWith("/")) {
|
|
||||||
s = s.substring(0, s.length() - 1);
|
|
||||||
}
|
}
|
||||||
return s;
|
|
||||||
|
/**
|
||||||
|
* @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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,13 +38,17 @@ violin:
|
|||||||
service-token-secret: ${VIOLIN_IAM_SERVICE_TOKEN_SECRET:}
|
service-token-secret: ${VIOLIN_IAM_SERVICE_TOKEN_SECRET:}
|
||||||
service-allowlist: ${VIOLIN_IAM_SERVICE_ALLOWLIST:}
|
service-allowlist: ${VIOLIN_IAM_SERVICE_ALLOWLIST:}
|
||||||
profile: ${SPRING_PROFILES_ACTIVE:local}
|
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:
|
authentik:
|
||||||
issuer: ${AUTHENTIK_ISSUER:}
|
token-endpoint: ${IAM_OIDC_AUTHENTIK_TOKEN_ENDPOINT:}
|
||||||
client-id: ${AUTHENTIK_CLIENT_ID:}
|
userinfo-endpoint: ${IAM_OIDC_AUTHENTIK_USERINFO_ENDPOINT:}
|
||||||
client-secret: ${AUTHENTIK_CLIENT_SECRET:}
|
authorize-endpoint: ${IAM_OIDC_AUTHENTIK_AUTHORIZE_ENDPOINT:}
|
||||||
redirect-uri: ${AUTHENTIK_REDIRECT_URI:}
|
|
||||||
scope: ${AUTHENTIK_SCOPE:openid profile email}
|
|
||||||
|
|
||||||
management:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*/
|
||||||
|
@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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user