Skip to content
Apollo-DeployPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Kotlin Machine-to-Machine OAuth

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.

Readiness

  • Ktor authorization server, resource-server protection, and outbound client_secret_basic client: READY.
  • private_key_jwt: PARTIAL. The server/core verification path exists when an application explicitly configures a ClientAssertionVerifier; 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 a SecurityFilterChain or method-security interceptor. The scope annotations are deprecated because they are not enforcement points.

Modules

  • 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, and private_key_jwt verification primitives.
  • oauth-m2m-ktor: Ktor authorization-server and resource-server adapters plus route DSL.
  • oauth-m2m-client: cached outbound token client and Ktor HttpClient bearer plugin.
  • oauth-m2m-storage: optional client-registry caching.
  • oauth-m2m-testing: configurable signed test-token fixtures.
  • oauth-m2m-spring: preview Spring Security adapter.

Ktor resource server

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.

Authorization server

The default endpoints are:

  • POST /oauth2/token
  • GET /.well-known/jwks.json
  • GET /.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.

Outbound client

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.

Keys, revocation, and rotation

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.

Advanced client authentication

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.

Testing

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")
}

Spring preview

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.

Verification

Run the complete SDK suite from this directory:

./gradlew test --warning-mode all

oauth-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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages