Skip to content

Add an OAuth2 BearerTokenProvider adapter module (client-credentials and refresh-token grants) #214

Description

@OmarAlJarrah

Summary

Ship a thin sdk-auth-oauth2 adapter module that implements BearerTokenProvider by actually acquiring tokens from an OAuth2 token endpoint, covering the two non-interactive grants: client_credentials and refresh_token. The provider builds the form-encoded token request, picks the client-authentication method (client_secret_basic vs client_secret_post), calls the token endpoint through an injected transport, and parses the response into a BearerToken. It reuses the existing HttpClient/AsyncHttpClient, Serde, and Clock seams, depends only on sdk-core, and pulls in no third-party runtime — so it stays an opt-in module, never part of core.

Problem

The bearer plumbing is complete, but it stops one step short of being usable end to end. What ships today:

  • BearerToken (sdk-core/src/main/kotlin/org/dexpace/sdk/core/auth/BearerToken.kt) — an access-token string plus an optional expiresAt, with isExpiredAt(now, marginBefore) for pre-emptive refresh (AUTH-10).
  • BearerTokenProvider (sdk-core/src/main/kotlin/org/dexpace/sdk/core/auth/BearerTokenProvider.kt) — the fun interface with fetch(scopes, params) / fetchAsync(scopes, params). Its own KDoc (lines 62-64) is explicit that this seam is the extension point and that "OAuth token-exchange flows belong in adapter modules, not in sdk-core."
  • BearerTokenAuthStep and AsyncBearerTokenAuthStep (sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/) — the AUTH-pillar consumers: single-flight refresh, a 30 s refresh margin, header-matched 401 eviction, and the async three-zone expiry policy (AUTH-34–AUTH-37).

What is missing is a shipped BearerTokenProvider implementation that talks to a real token endpoint. There is no auth adapter module at all — settings.gradle.kts includes sdk-core, sdk-io-okio3, the four sdk-async-* adapters, the two transports, sdk-serde-jackson, and the two unpublished test/example modules (sdk-shrink-test, sdk-example); no sdk-auth-* module exists.

This matters because the consumers of this platform are external — generated service clients, downstream SDKs, and applications built on top of the toolkit. Every one of them that authenticates with OAuth2 (OAUTH2 heads the recognized scheme set, AUTH-1, and is the only scheme whose scopes and params the descriptor model carries, AUTH-2) currently has to hand-write the token-endpoint exchange itself: form encoding, client_secret_basic vs client_secret_post, parsing expires_in into an absolute expiry, and threading the async path so a refresh does not block the dispatch thread and a failure surfaces through a failed future rather than a synchronous throw (AUTH-11). That is fiddly, security-sensitive, and identical across every consumer. Each hand-rolled copy is a place to get client authentication or expiry math subtly wrong. A reference provider turns "wire up OAuth2" into "add a dependency and construct one object."

Proposed approach

A new published module, sdk-auth-oauth2, applying id("dexpace.published-module"), depending on sdk-core only. Because the token request is itself just an HTTP call, the module needs no third-party runtime of its own: the transport, the JSON codec, and the clock are all injected seams. Keeping the exchange out of core preserves SEAM-1 (core embeds no concrete transport or codec and carries no third-party runtime) and follows the minimal-footprint principle (§2): optional capabilities are separately installable units depending on core plus at most one third-party library — here, zero.

Sketch of the public surface:

public class OAuth2TokenProvider private constructor(...) : BearerTokenProvider {
    override fun fetch(scopes: List<String>, params: Map<String, Any>): BearerToken
    override fun fetchAsync(scopes: List<String>, params: Map<String, Any>): CompletableFuture<BearerToken>

    public class Builder : org.dexpace.sdk.core.generics.Builder<OAuth2TokenProvider> {
        public fun tokenEndpoint(url: String): Builder            // https-only, validated
        public fun clientCredentials(id: String, secret: String): Builder
        public fun grant(grant: OAuth2Grant): Builder             // CLIENT_CREDENTIALS | REFRESH_TOKEN
        public fun refreshToken(token: String): Builder           // required for REFRESH_TOKEN
        public fun clientAuth(method: ClientAuthMethod): Builder  // CLIENT_SECRET_BASIC (default) | CLIENT_SECRET_POST
        public fun transport(client: HttpClient): Builder
        public fun asyncTransport(client: AsyncHttpClient): Builder
        public fun serde(serde: Serde): Builder                   // parses the token response
        public fun clock(clock: Clock): Builder                   // defaults to Clock.SYSTEM
        override fun build(): OAuth2TokenProvider
    }
}

Behavior:

  • Request construction. Build a POST to the token endpoint with an application/x-www-form-urlencoded body via the existing RequestBody.create(formData, charset) factory (sdk-core/.../http/request/RequestBody.kt, line 220), so no new encoding code is introduced. Parameters follow RFC 6749: grant_type=client_credentials (§4.4) or grant_type=refresh_token + refresh_token=… (§6), plus scope from the step-supplied scopes and any pass-through params.
  • Client authentication. Default client_secret_basic (HTTP Basic over client_id:client_secret, RFC 6749 §2.3.1); client_secret_post puts client_id/client_secret in the form body instead. Selectable on the builder.
  • Response parsing. Deserialize the token response (RFC 6749 §5.1: access_token, token_type, expires_in, optional refresh_token, scope) through the injected Serde (SEAM-19/SEAM-21) into a BearerToken, mapping the relative expires_in to an absolute expiresAt = clock.now() + expires_in using the injected Clock. A token-endpoint error (§5.2) or non-2xx surfaces as an exception.
  • Sync vs async. fetch uses the injected HttpClient (SEAM-11); fetchAsync uses the injected AsyncHttpClient (SEAM-16) so a refresh never blocks the dispatch thread and a failure completes the future exceptionally rather than throwing synchronously (AUTH-11). Where only a blocking transport is available, the sync↔async bridge rules apply (SEAM-18).
  • Resource ownership. The provider never closes a caller-supplied transport (SEAM-14); it owns nothing it did not create.
  • Caching. The provider need not cache — the BearerTokenAuthStep/AsyncBearerTokenAuthStep cache and single-flight already sit above it — but the BearerTokenProvider KDoc note about the step cache being per-step (not process-wide) should be repeated so consumers sharing one provider across steps understand the trade-off.

Scope and non-goals

In scope:

  • client_credentials and refresh_token grants.
  • client_secret_basic and client_secret_post client authentication.
  • Sync (fetch) and async (fetchAsync) paths over the injected transports.
  • Absolute-expiry mapping from expires_in; the produced BearerToken redacts its own token in diagnostics.

Non-goals (leave for follow-ups, keep this module thin):

  • Interactive/browser grants: authorization_code (with or without PKCE), device-code, implicit.
  • Assertion / token-exchange grants: JWT-bearer, SAML-bearer, RFC 8693 token exchange, private_key_jwt / client_secret_jwt client authentication.
  • Per-cloud workload-identity providers (GCP / Azure / Kubernetes) — the same BearerTokenProvider KDoc (line 62) calls these out as their own adapters, not this one.
  • OAuth2 metadata/discovery (RFC 8414), dynamic client registration.
  • Any change to sdk-core. The core seams are sufficient as they stand; this module only consumes them.

Acceptance criteria

  • New sdk-auth-oauth2 module in settings.gradle.kts, applying id("dexpace.published-module"), depending on sdk-core only, with no third-party runtime dependency; the MIT license header and explicit-API strict-mode conventions hold, and apiDump produces a committed .api snapshot.
  • OAuth2TokenProvider implements BearerTokenProvider and drops into both BearerTokenAuthStep and AsyncBearerTokenAuthStep unchanged.
  • client_credentials and refresh_token requests are form-encoded per RFC 6749 §4.4 / §6 using RequestBody.create(Map<String, String>, …); client_secret_basic (default) and client_secret_post are both exercised.
  • The token response (§5.1) parses through an injected Serde; expires_in maps to an absolute expiresAt via the injected Clock (default Clock.SYSTEM), so the additive-margin expiry check the step relies on evaluates correctly (AUTH-10).
  • The token endpoint MUST be HTTPS, validated at construction — extending the same no-credential-over-plaintext guarantee the auth step enforces on resource requests (AUTH-28) to the provider's own token request.
  • A token-endpoint error or non-2xx response propagates as an exception and is not swallowed; the async path delivers it through a failed future, never a synchronous throw (AUTH-11).
  • A caller-supplied transport is never closed by the provider (SEAM-14).
  • The provider and its builder do not leak the client secret or refresh token in toString/diagnostics (security-by-default; the produced BearerToken already redacts its own token, AUTH-8).
  • Tests use mockwebserver3 following the existing transport-suite patterns; both grants, both client-auth methods, expiry mapping (with an injected fake Clock), and the error path are covered, and the module clears the aggregate coverage floor.

References

  • Spec (docs/product-spec.md): SEAM-1 (core embeds no concrete transport/codec/I-O and depends at runtime on nothing beyond the standard library plus a logging facade) and the "minimal-footprint core" principle (§2 — optional capabilities are separately installable units depending on core plus at most one third-party library; here, zero); AUTH-1 / AUTH-2 (OAUTH2 heads the recognized scheme set; scopes and params are bound to, and meaningful only for, the OAUTH2 requirement); AUTH-8 (every credential redacts its secret in diagnostic output); AUTH-10 (optional expiry, additive grace margin); AUTH-11 (a provider's fetch errors propagate and are not cached, so a later request retries; async callers observe the error through a failed future, never a synchronous throw); AUTH-28 (no credential stamped over plaintext — the guarantee the token request must also uphold); AUTH-34–AUTH-38 (the sync and async bearer-step behavior the provider feeds); SEAM-11 / SEAM-16 (sync / async transport seams); SEAM-18 (sync↔async bridge rules); SEAM-19 / SEAM-21 (wire-codec seam); SEAM-14 (bring-your-own resource ownership).
  • Source: sdk-core/src/main/kotlin/org/dexpace/sdk/core/auth/BearerToken.kt; sdk-core/src/main/kotlin/org/dexpace/sdk/core/auth/BearerTokenProvider.kt (KDoc lines 62-64 designate adapter modules as the home for OAuth flows); sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/BearerTokenAuthStep.kt; sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AsyncBearerTokenAuthStep.kt; sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestBody.kt (form-encoded body factory, line 220); sdk-core/src/main/kotlin/org/dexpace/sdk/core/util/Clock.kt (Clock.SYSTEM); sdk-core/src/main/kotlin/org/dexpace/sdk/core/generics/Builder.kt (the Builder<out T> contract). sdk-serde-jackson is a good structural template for a thin single-entry-point adapter module.
  • Standards: RFC 6749 §2.3.1 (client_secret_basic / client_secret_post client authentication), §4.4 (client-credentials grant), §5.1 (token response), §5.2 (error response), §6 (refresh-token grant).
  • Related: Add an async bearer-auth step with background token refresh #32 (closed — added the async bearer-auth step with background refresh) established the step-side consumer; this issue supplies the provider that feeds it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions