A focused OAuth 2.0 Client Credentials SDK for Kotlin service-to-service authentication. It does not implement users, sessions, refresh tokens, authorization-code flow, or OpenID Connect.
- Ktor authorization server, resource-server protection, and outbound
client_secret_basicclient: READY. private_key_jwt: PARTIAL. The server/core verification path exists when an application explicitly configures aClientAssertionVerifier; the outbound client does not construct client assertions yet.- mTLS: PARTIAL. The core policy and certificate-thumbprint credential check exist, but the Ktor server intentionally relies on an application-provided, trusted TLS-termination adapter to extract the client certificate thumbprint.
- Spring: PREVIEW. The module exposes a verifier/authentication token and
SCOPE_...authorities, but it does not install aSecurityFilterChainor method-security interceptor. The scope annotations are deprecated because they are not enforcement points.
oauth-m2m-core: clients, credentials, policy, token contracts, principal, errors, events, and secret hashing.oauth-m2m-jwt: RS256 access-token issuer/verifier, key rotation, JWKS, revocation checks, andprivate_key_jwtverification primitives.oauth-m2m-ktor: Ktor authorization-server and resource-server adapters plus route DSL.oauth-m2m-client: cached outbound token client and KtorHttpClientbearer plugin.oauth-m2m-storage: optional client-registry caching.oauth-m2m-testing: configurable signed test-token fixtures.oauth-m2m-spring: preview Spring Security adapter.
install(MachineOAuth) {
issuer("https://identity.internal.example")
audience("orders-api")
}
routing {
machine("orders.read") {
get("/orders") {
val machine = call.machinePrincipal()
call.respondText(machine.clientId.value)
}
}
machineAny("orders.read", "orders.admin") {
get("/orders/lookup") { call.respondText("ok") }
}
machineAuthenticated {
get("/whoami") {
call.requireScope("profile.read")
call.respondText(call.machinePrincipal().clientId.value)
}
}
}machine(...) requires every listed scope, machineAny(...) requires at least one, and machineAuthenticated is the
explicit authentication-only form. Empty scope sets are rejected for both the vararg and builder-style machine APIs so
a scope typo cannot silently weaken a route to authentication-only.
Missing, malformed, expired, wrong-issuer, and wrong-audience bearer tokens return 401 Unauthorized with
WWW-Authenticate: Bearer. A valid machine token that lacks route/client authority returns 403 Forbidden.
call.requireScope, requireAllScopes, and requireAnyScope use the same 403 semantics inside authenticated machine
routes.
Custom validators return 403 when they return false. If a validator throws unexpectedly, the exception remains an
application/server failure rather than being relabeled as a normal authorization denial; use normal Ktor error
monitoring to observe it.
If MachineOAuth creates its own HTTP client for remote metadata/JWKS retrieval, that client is closed with the Ktor
application. A caller-supplied httpClient remains caller-owned and is never closed by the SDK.
The default endpoints are:
POST /oauth2/tokenGET /.well-known/jwks.jsonGET /.well-known/oauth-authorization-server
install(MachineOAuthServer) {
issuer("https://identity.internal.example")
signingKeys(keyProvider)
clientRegistry = InMemoryClientRegistry {
client("orders-worker") {
secretHash("primary", storedHash)
scopes("orders.read")
audiences("orders-api")
metadata["organization"] = "org-123"
}
}
claimsContributor { context ->
mapOf("organization_id" to context.client.metadata["organization"])
}
}Claim contributors can add application claims, but cannot replace iss, sub, aud, exp, iat, jti, client_id,
or scope. The verifier exposes accepted custom claims through MachinePrincipal.claim<T>(name).
authorizationPolicy can add dynamic token-issuance policy after static scope/audience checks, and events(...) can
receive authentication/issuance/rejection events. Event handlers and policy code should not log raw secrets, assertions,
or bearer tokens.
Only one client-registry configuration path may be supplied. Configuring both clientRegistry and the legacy clients
property fails at startup rather than silently choosing one.
Authorization-server metadata advertises only capabilities the configured server can actually accept:
client_credentials, client_secret_basic, and private_key_jwt/RS256 only when a private-key assertion verifier is
configured. mTLS is not advertised by default because certificate extraction is deployment-specific.
val oauth = MachineOAuthClient {
tokenEndpoint("https://identity.internal.example/oauth2/token")
clientId(System.getenv("CLIENT_ID"))
clientSecret(System.getenv("CLIENT_SECRET"))
audience("orders-api")
scopes("orders.read")
maxTokenResponseBytes(32 * 1024)
}
val token = oauth.accessToken()The token endpoint itself must be an absolute HTTPS URL. Tokens are cached by (resource, sorted scopes) and refreshed
30 seconds early by default. Same-key concurrent refreshes are single-flight; independent cache keys can refresh
concurrently. Temporary transport failures and HTTP 502/503/504 responses receive a small bounded coroutine retry with
exponential jitter. OAuth 4xx responses and malformed responses are not retried. Token-endpoint response bodies are
bounded (32 KiB by default, configurable up to 1 MiB).
For automatic bearer attachment:
val serviceClient = HttpClient(CIO) {
install(MachineOAuthClientPlugin) {
this.oauth = oauth
resource = "orders-api"
scopes = setOf("orders.read")
hosts("orders.internal")
}
}The plugin attaches an SDK-issued bearer token only when the actual request hop is both HTTPS and in hosts(...). It
checks every send, including redirects. SDK-injected authorization is removed before an HTTPS-to-HTTP downgrade or an
untrusted redirect target. An Authorization header supplied by the application is left unchanged.
RS256 is the supported access-token algorithm. InMemorySigningKeyProvider.rotate(...) keeps previous public keys
available so already-issued short-lived tokens can verify during a rollout; retire an old key only after its
token-validity overlap has passed. Production signing-key persistence/HSM/KMS integration belongs in a
SigningKeyProvider, not in route code.
A TokenRevocationStore is optional. If configured, revocation lookup failures fail closed. For most internal service
traffic, short TTLs plus credential disable/rotation are simpler than maintaining large revocation state.
client_secret_basic is the production-ready path. Client secrets are hashed in registries and redacted by
SecretValue; JVM strings/heap objects cannot provide reliable zeroization, so keep plaintext references short-lived
and never log or persist them.
private_key_jwt verification and replay protection primitives exist, and the Ktor token endpoint can accept assertions
when privateKeyJwtVerifier is configured. The outbound MachineOAuthClient does not yet construct/sign client
assertions, so this path remains partial.
mTLS credentials and requireMtls policy exist, but a deployment must provide a trustworthy
clientCertificateThumbprint callback sourced from its actual TLS boundary. requireMtls and requirePrivateKeyJwt
cannot both be enabled for one client because one token request cannot satisfy both authentication modes in this SDK.
oauth-m2m-testing can create signed fixtures without running an auth server. Tests can control issuer, client id,
subject (including intentionally invalid sub != client_id tokens), audience, scopes, JTI, clock/issued-at, TTL/expiry,
custom claims, and signing key. The resulting fixture exposes its verification key.
val now = Instant.parse("2026-08-08T00:00:00Z")
val token = TestMachineToken.create {
issuer = "https://identity.test"
clientId = "orders-worker"
audience = "orders-api"
scopes("orders.read")
tokenId = "fixture-jti"
clock(FixedOAuthClock(now))
claim("organization_id", "org-123")
}MachineOAuthResourceServer can verify a token and produce MachineOAuthAuthenticationToken, whose authorities are
mapped as SCOPE_<scope>. The module does not currently install a servlet filter/bearer resolver, exception
translation, or method-security enforcement. @RequireMachineScope, @RequireMachineScopes, and
@RequireAnyMachineScope are therefore deprecated preview metadata; use normal Spring Security authority rules or the
MachinePrincipal.require... helpers until full integration exists.
Run the complete SDK suite from this directory:
./gradlew test --warning-mode alloauth-m2m-jwt uses Nimbus JOSE + JWT 10.9.1 for JOSE mechanics and JWK/JWKS parsing. Network trust, HTTPS
requirements, algorithm allow-listing, issuer/audience validation, token limits, key refresh policy, and application
authorization remain explicit SDK policy.