Skip to content

Authenticate and authorize Arc endpoints

Establish trust before accepting platform headers

Section titled “Establish trust before accepting platform headers”

The optional platform identity bridge is off by default. Base64 is unsigned data, never proof of sender identity. Enable cratis.arc.platform-identity.enabled=true only behind authenticated ingress that strips caller-supplied x-ms-client-principal, x-ms-client-principal-id, and x-ms-client-principal-name headers and rewrites them from verified identity. Block direct backend access. Header parsing alone cannot establish either of these deployment guarantees.

Supply an ArcPlatformIdentityTrust bean that verifies your deployment’s ingress boundary on each submission. Without it, the default policy rejects every platform submission. Do not return true because X-Forwarded-For, Forwarded, or another caller-authored header claims a trusted address. An exact request.remoteAddr comparison can be part of an isolated deployment’s policy, but only when it represents a verified transport peer. Forwarded-request wrappers and container valves can rewrite it: disable address rewriting or configure and verify trusted proxy handling before using this value. Do not resolve caller-provided hostnames or grant trust to the public client address. Arc does not install forwarding configuration, TLS credentials, firewall rules, or proxy allowlists.

Spring Security remains optional. With security absent or the property disabled, Arc registers no platform converter, filter, or chain, and platform headers do not authenticate requests.

When enabled and no application SecurityFilterChain exists, Arc supplies a conservative chain: every route requires authentication, including otherwise anonymous Arc metadata routes. CSRF, security headers, and Spring’s session defaults remain enabled; POST and other unsafe requests need a valid CSRF token. Missing authentication returns 401, access denial (including CSRF) returns 403, without login redirects or error redispatch. Form login and HTTP Basic are not added by this chain. This policy deliberately favors denial over accidentally exposing application routes; configure an application chain for any anonymous routes or alternative authentication mechanisms.

An application chain is never rewritten. Explicitly add the provided filter after your existing authentication mechanisms and before AnonymousAuthenticationFilter, as in this Java configuration:

@Bean
SecurityFilterChain applicationChain(HttpSecurity http, ArcPlatformAuthenticationFilter filter) throws Exception {
http.addFilterBefore(filter, AnonymousAuthenticationFilter.class);
http.authorizeHttpRequests(rules -> rules.requestMatchers("/.cratis/commands").permitAll()
.anyRequest().authenticated());
http.exceptionHandling(errors -> errors.authenticationEntryPoint(
(request, response, failure) -> response.setStatus(401)));
return http.build();
}

Keep the disabled arcPlatformAuthenticationFilterRegistration bean: it prevents Boot servlet registration and double execution outside Spring Security. With multiple application chains, install the filter only in chains intended to accept this identity. A chain without it ignores the headers. Do not register it independently as a servlet filter. Applications may replace the trust bean, the named arcPlatformAuthenticationConverter (AuthenticationConverter), or the filter bean; custom converters own equivalent validation and must report rejected credentials as AuthenticationException. Unrelated converters do not disable the named default.

Valid platform submissions never replace an already authenticated non-anonymous Spring identity. Invalid submissions fail with a generic 401 even when an existing identity is present (CSRF or an earlier security filter may reject first). The bridge saves its context only in request attributes for redispatch, never in the HTTP session; keep Spring’s request-attribute security-context repository support when customizing a chain. The servlet thread context is restored on exit. Arc captures the resulting principal before coroutine work, without later servlet or security thread-local reads. The client-readable identity cache cookie cannot authenticate a later request.

A submission requires exactly one of each of the three headers; no headers means no attempt. Partial or repeated headers, invalid base64, malformed UTF-8/JSON, duplicate JSON keys, trailing values, unknown fields, incorrect types, duplicate claim pairs, duplicate roles, control characters, and exceeded limits fail with no payload or parser cause in the failure. Canonical padded standard base64 is required, not URL-safe base64 or whitespace-wrapped data. Configure your container and proxy header limits too: they may reject a request before Arc sees it.

The JSON object accepts identityProvider, userId, userDetails, userRoles, and claims only. userDetails is a required nonblank display name. Optional userId is a nonblank string but is not the canonical ID. Optional identityProvider is a string; missing or blank means no provider claim. userRoles is an optional array of nonblank strings; claims is an optional array of exact {"typ":"type","val":"value"} objects. Explicit null is not absence. Claim types are nonblank; empty claim values are allowed. ID/name headers must be nonblank; the name header is required by the wire contract, but the display name comes from userDetails.

Limits are fixed: 32,768 encoded characters, 24,576 decoded bytes, 2,048 characters per string/header ID/name, 128 input claims, 64 roles (including role claims), nesting depth 4, 2,048 JSON tokens, 256-character JSON property names, and 32-character numeric tokens. There is no application-mapper coercion or polymorphic type activation. Repeated claim types with distinct values remain multivalued.

The ID header replaces exact sub and the standard nameidentifier claim. Reserved urn:cratis:arc:identity:provider collisions are removed case-insensitively, then one nonblank identityProvider value is retained verbatim, including surrounding spaces. Other claims, including urn:cratis:identity:provider-key, survive. Standard name and role claims are added from userDetails and userRoles; standard role claims also become Spring ROLE_ authorities. Arc captures these as unprefixed roles and scheme MicrosoftIdentityPlatform. This scheme describes the bridge, not an independently verified signature or a claim that the application uses Microsoft token validation.

These JVM security boundaries are intentionally stricter than the inspected Arc .NET handler, including rejecting partial submissions rather than treating them as anonymous. Do not log the headers, claims, or identity cookie when troubleshooting ingress.

Arc authenticates requests through ordered AuthenticationHandler beans before protected command, query, identity, and diagnostics endpoints run. A handler receives an immutable AuthenticationRequestContext containing case-insensitive headers, cookies, the principal captured by the host, and the selected tenant when one exists.

Return AuthenticationResult.ANONYMOUS when the handler does not recognize the request. Return failed when it recognizes credentials but rejects them, or succeeded with an ArcPrincipal when it accepts them:

@Bean
@Order(10)
fun bearerAuthentication(): AuthenticationHandler = AuthenticationHandler { context ->
when (context.header("Authorization")) {
null -> AuthenticationResult.ANONYMOUS
"Bearer valid-token" -> AuthenticationResult.succeeded(
ArcPrincipal(
name = "Ada",
isAuthenticated = true,
roles = setOf("admin"),
id = "user-42",
authenticationScheme = "Bearer"
)
)
else -> AuthenticationResult.failed(AuthenticationFailureReason.of("invalid-token"))
}
}

A success or failure is terminal: no later handler runs. A failed handler can therefore never be overridden by a later success. Anonymous is not a rejection and lets the chain continue; it becomes the final result only when no handler recognized the request. The HTTP response exposes failures only as a generic 401 and never returns the handler’s private failure reason.

Kotlin AuthenticationHandler beans execute in Spring order, followed by Java AsyncAuthenticationHandler beans in Spring order. When ordering across both implementation styles must be global, register the Java handler through AsyncAuthenticationHandlerAdapter as an AuthenticationHandler bean.

Java implements AsyncAuthenticationHandler with CompletionStage, without coroutine types:

@Bean
@Order(20)
AsyncAuthenticationHandler apiKeyAuthentication() {
return context -> {
String value = context.header("X-Api-Key");
AuthenticationResult result;
if (value == null) {
result = AuthenticationResult.ANONYMOUS;
} else if (value.equals("valid-key")) {
result = AuthenticationResult.succeeded(
new ArcPrincipal("Ada", true, Set.of("operator"), "user-42", List.of(), "ApiKey"));
} else {
result = AuthenticationResult.failed(AuthenticationFailureReason.of("invalid-api-key"));
}
return CompletableFuture.completedFuture(result);
};
}

Register the async handler itself as a bean; Arc adapts it to the coroutine-first chain and propagates cancellation. Do not block the request thread while verifying credentials.

Declare authorization on artifacts and operations

Section titled “Declare authorization on artifacts and operations”

Arc generates authorization metadata from @Authorize, @Roles, and @AllowAnonymous on a command or read-model class and its operation (handle or the query function).

@Authorize(policy = "activeSubscription")
@Roles("member")
@Command
class UpdateProfile {
fun handle(): Unit = Unit
}

An authorization decision with several dimensions requires all declared dimensions:

  • The caller must be authenticated unless @AllowAnonymous applies.
  • A named policy must return AuthorizationResult.success().
  • The caller must hold at least one role from the combined role list.
  • The captured authentication scheme must match at least one declared scheme, ignoring case.

Register named policies as Spring beans. The bean name is the policy name used by @Authorize:

@Bean("activeSubscription")
fun activeSubscription(): AuthorizationPolicy = AuthorizationPolicy { principal ->
if (principal.claims.any { it.type == "subscription" && it.value == "active" }) {
AuthorizationResult.success()
} else {
AuthorizationResult.failure("An active subscription is required.")
}
}

Java policies use BlockingAuthorizationPolicyAdapter or AsyncAuthorizationPolicyAdapter, so Java implementations never implement a suspending method.

The class states the default. An operation that declares any authorization metadata of its own — @AllowAnonymous, @Authorize, or @Roles — replaces that default entirely rather than merging with it. Only when an operation declares none does the class apply.

Replacement rather than merging is what makes narrowing trustworthy. An operation that asks for @Roles("admin") requires exactly that role, and a role the class happened to list cannot satisfy it by accident.

The same rule reads in the other direction, which is how a protected read model exposes the one query a login screen needs before anybody has signed in:

@ReadModel
@Authorize
public data class AuthenticationQueryItem(public val message: String) {
public companion object {
// Overrides the class: anyone may subscribe to this one.
@JvmStatic
@AllowAnonymous
public fun anonymous(@FromServices source: AuthenticationQuerySource): Flow<AuthenticationQueryItem> =
source.observeAnonymous()
// Declares nothing, so the class-level @Authorize applies.
@JvmStatic
public fun authenticated(@FromServices source: AuthenticationQuerySource): Flow<AuthenticationQueryItem> =
source.observeAuthenticated()
}
}

Repeated @Roles declarations on the same target combine, and holding any one listed role satisfies the role check. Policy, role, and scheme checks are still cumulative.

Spring Security remains optional. When it is on the classpath, Arc maps the request’s Spring Authentication into an ArcPrincipal, retaining its name, authenticated state, string-valued authorities, claims, stable identity identifier, and authentication scheme. Authorities prefixed with ROLE_ become Arc role names without the prefix; authorities without a string representation are ignored.

Without Spring Security, Arc uses the servlet principal. In both cases Arc captures the principal before asynchronous work begins, so command and query pipelines never depend on thread-local security state after suspension.

An application can replace Arc’s complete Authentication service with its own bean. Supplying that override bypasses the default ordered handler aggregator and is appropriate only when the application owns the entire chain.

Register one IdentityDetailsProvider<T> to enable GET /.cratis/me. It receives the authenticated principal and returns both the application’s authorization decision and its typed identity payload:

data class ApplicationIdentity(val displayName: String)
@Bean
fun identityDetails(): IdentityDetailsProvider<ApplicationIdentity> =
object : IdentityDetailsProvider<ApplicationIdentity> {
override val detailsType = ApplicationIdentity::class.java
override suspend fun provide(context: IdentityProviderContext): IdentityDetails<ApplicationIdentity> =
IdentityDetails(
isUserAuthorized = context.claims.any { it.type == "role" && it.value == "member" },
details = ApplicationIdentity(context.name)
)
}

Java can register AsyncIdentityDetailsProvider<T> through AsyncIdentityDetailsProviderAdapter. Arc permits exactly one identity-details provider and fails application startup when several are present.

The identity cache cookie is client-readable by design and is excluded from authentication input, so a client can never authenticate by replaying Arc’s cached identity projection.

The following rules describe Arc’s own authentication layer; an application or the enabled platform Spring Security chain can impose stricter rules before Arc runs. When no Arc authentication handlers are registered, Arc endpoint behavior is unchanged and the captured host principal is used. Once handlers exist, the following literal metadata routes remain anonymous:

  • GET /.cratis/commands
  • GET /.cratis/queries
  • GET /.cratis/users
  • GET /.cratis/tenants
  • GET /.cratis/identity-details/schema

GET /.cratis/me requires an authenticated principal because it returns caller-specific identity details.

The fixed multiplexed SSE and WebSocket routes accept an anonymous physical connection, while every subscription still runs its query’s authorization pipeline independently. One unauthorized subscription terminates without disturbing authorized subscriptions sharing the connection.

GET and QUERY /.cratis/queries/health require authentication whenever handlers are registered because the snapshot includes connection identifiers, subscription identifiers, remote addresses, user agents, and user identities. This endpoint is diagnostics, not container health; use Spring Boot Actuator health groups for liveness and readiness.

Distinguish authentication and authorization failures

Section titled “Distinguish authentication and authorization failures”

Authentication establishes who the caller is and fails with HTTP 401 when credentials are rejected or a protected endpoint receives no authenticated principal. Authorization decides whether that principal may execute a specific command or query and fails with HTTP 403.

Arc returns generic transport failures and keeps private handler or policy details out of the response. Use server-side debug logs, traces, and the stable result envelope when diagnosing a rejection; never return credential or policy internals to the caller.