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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions