You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
Per-cloud workload-identity providers (GCP / Azure / Kubernetes) — the same BearerTokenProvider KDoc (line 62) calls these out as their own adapters, not this one.
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.
Summary
Ship a thin
sdk-auth-oauth2adapter module that implementsBearerTokenProviderby actually acquiring tokens from an OAuth2 token endpoint, covering the two non-interactive grants:client_credentialsandrefresh_token. The provider builds the form-encoded token request, picks the client-authentication method (client_secret_basicvsclient_secret_post), calls the token endpoint through an injected transport, and parses the response into aBearerToken. It reuses the existingHttpClient/AsyncHttpClient,Serde, andClockseams, depends only onsdk-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 optionalexpiresAt, withisExpiredAt(now, marginBefore)for pre-emptive refresh (AUTH-10).BearerTokenProvider(sdk-core/src/main/kotlin/org/dexpace/sdk/core/auth/BearerTokenProvider.kt) — thefun interfacewithfetch(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 insdk-core."BearerTokenAuthStepandAsyncBearerTokenAuthStep(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
BearerTokenProviderimplementation that talks to a real token endpoint. There is no auth adapter module at all —settings.gradle.ktsincludessdk-core,sdk-io-okio3, the foursdk-async-*adapters, the two transports,sdk-serde-jackson, and the two unpublished test/example modules (sdk-shrink-test,sdk-example); nosdk-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_basicvsclient_secret_post, parsingexpires_ininto 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, applyingid("dexpace.published-module"), depending onsdk-coreonly. 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:
Behavior:
POSTto the token endpoint with anapplication/x-www-form-urlencodedbody via the existingRequestBody.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) orgrant_type=refresh_token+refresh_token=…(§6), plusscopefrom the step-suppliedscopesand any pass-throughparams.client_secret_basic(HTTP Basic overclient_id:client_secret, RFC 6749 §2.3.1);client_secret_postputsclient_id/client_secretin the form body instead. Selectable on the builder.access_token,token_type,expires_in, optionalrefresh_token,scope) through the injectedSerde(SEAM-19/SEAM-21) into aBearerToken, mapping the relativeexpires_into an absoluteexpiresAt = clock.now() + expires_inusing the injectedClock. A token-endpoint error (§5.2) or non-2xx surfaces as an exception.fetchuses the injectedHttpClient(SEAM-11);fetchAsyncuses the injectedAsyncHttpClient(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).BearerTokenAuthStep/AsyncBearerTokenAuthStepcache and single-flight already sit above it — but theBearerTokenProviderKDoc 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_credentialsandrefresh_tokengrants.client_secret_basicandclient_secret_postclient authentication.fetch) and async (fetchAsync) paths over the injected transports.expires_in; the producedBearerTokenredacts its own token in diagnostics.Non-goals (leave for follow-ups, keep this module thin):
authorization_code(with or without PKCE), device-code, implicit.private_key_jwt/client_secret_jwtclient authentication.BearerTokenProviderKDoc (line 62) calls these out as their own adapters, not this one.sdk-core. The core seams are sufficient as they stand; this module only consumes them.Acceptance criteria
sdk-auth-oauth2module insettings.gradle.kts, applyingid("dexpace.published-module"), depending onsdk-coreonly, with no third-party runtime dependency; the MIT license header and explicit-API strict-mode conventions hold, andapiDumpproduces a committed.apisnapshot.OAuth2TokenProviderimplementsBearerTokenProviderand drops into bothBearerTokenAuthStepandAsyncBearerTokenAuthStepunchanged.client_credentialsandrefresh_tokenrequests are form-encoded per RFC 6749 §4.4 / §6 usingRequestBody.create(Map<String, String>, …);client_secret_basic(default) andclient_secret_postare both exercised.Serde;expires_inmaps to an absoluteexpiresAtvia the injectedClock(defaultClock.SYSTEM), so the additive-margin expiry check the step relies on evaluates correctly (AUTH-10).toString/diagnostics (security-by-default; the producedBearerTokenalready redacts its own token, AUTH-8).mockwebserver3following the existing transport-suite patterns; both grants, both client-auth methods, expiry mapping (with an injected fakeClock), and the error path are covered, and the module clears the aggregate coverage floor.References
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).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(theBuilder<out T>contract).sdk-serde-jacksonis a good structural template for a thin single-entry-point adapter module.client_secret_basic/client_secret_postclient authentication), §4.4 (client-credentials grant), §5.1 (token response), §5.2 (error response), §6 (refresh-token grant).