Skip to content

feat: add authentication credentials, token cache, and auth policies#8

Merged
OmarAlJarrah merged 6 commits into
mainfrom
feat/auth
Jun 16, 2026
Merged

feat: add authentication credentials, token cache, and auth policies#8
OmarAlJarrah merged 6 commits into
mainfrom
feat/auth

Conversation

@OmarAlJarrah

Copy link
Copy Markdown
Member

Summary

Adds authentication to the SDK — credentials, a token cache, and the policies that apply them — so requests can be authenticated through the pipeline.

  • Credentials (Dexpace.Sdk.Core.Auth): a vendor-neutral TokenCredential (async GetTokenAsync + sync bridge) with AccessToken / TokenRequestContext, plus ApiKeyCredential (configurable header + optional scheme) and BasicCredential.
  • AccessTokenCache: caches tokens per request context with proactive refresh (honoring RefreshOn), per-key single-flight (no stampede — verified with 50 concurrent callers), reference-atomic publication (no torn reads on the lock-free fast path), and refresh-failure-while-valid tolerance. TimeProvider-driven for testability.
  • Auth policies (Auth stage): ApiKeyAuthPolicy, BasicAuthPolicy, BearerTokenAuthPolicy (token via the cache), sharing an AuthorizationPolicy base that withholds and strips credentials on cross-origin hops — defense-in-depth, independent of RedirectPolicy, so a credential can't leak to a foreign origin.

No new package dependencies; Core stays BCL + the standard abstractions.

Follow-ups (the rest of the auth design): RFC 7235 challenge parsing + the Basic challenge handler (#7), and the Digest handler (#2).

Test plan

  • dotnet build -c Release clean on net8.0 + net10.0 (warnings-as-errors)
  • dotnet format --verify-no-changes clean
  • dotnet test -c Release passes (252 tests: 235 core, 17 serialization)

🤖 Generated with Claude Code

OmarAlJarrah and others added 6 commits June 16, 2026 17:35
Add AccessToken (readonly struct with Token, ExpiresOn, RefreshOn),
TokenRequestContext (scopes + claims + stable CacheKey), TokenCredential
(abstract base with blocking sync bridge), ApiKeyCredential (header/scheme
stamping), and BasicCredential (RFC 7617 ToBase64). 21 new tests covering
construction, property round-trips, cache-key stability, and sync bridge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add AccessTokenCache wrapping a TokenCredential. Tokens are served from
cache while now < ExpiresOn and (RefreshOn is null or now < RefreshOn).
Once RefreshOn is reached, a SemaphoreSlim (one per cache key) serializes
refreshes so only one credential call fires under concurrent load. Refresh
failures while a valid token exists are swallowed; failures with no valid
token propagate. TimeProvider is injected for deterministic testing.

7 new tests covering: within-validity caching, proactive RefreshOn trigger,
ExpiresOn expiry, 50-concurrent-caller single-flight, swallowed failure
while valid, propagated failure with no valid token, and independent per-key
caching.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AccessToken is a multi-field struct (string ref + two DateTimeOffset +
nullable flag). Writing it directly to a bare field and reading it on the
lock-free fast path races: the .NET memory model only guarantees atomicity
for pointer/word-size writes, so a concurrent fast-path reader could observe
a torn or partially-written value.

Fix: introduce a private sealed TokenHolder that boxes the struct behind a
single reference. CacheEntry replaces its AccessToken? field with a
volatile TokenHolder? _holder. The volatile modifier gives acquire semantics
on every fast-path read and release semantics on every slow-path write, and
reference reads/writes are unconditionally atomic, so no lock is needed on
the fast path.

Also fix the failure-while-valid catch block: the now variable was captured
before the possibly-slow GetTokenAsync call. The catch now re-samples the
clock (nowAfterFailure) so a token that expired during a sluggish failing
refresh is not incorrectly returned as valid.

Minor: replace "goroutine" (Go terminology) with "caller" in the XML doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduces three new files under src/Dexpace.Sdk.Core/Pipeline/Policies/:

- AuthorizationPolicy — abstract base class at PipelineStage.Auth that
  implements cross-origin withholding: on the first invocation the request
  origin (scheme + host + port) is recorded in the context property bag under
  "dexpace.auth.origin"; subsequent invocations on a context whose Request has
  moved to a different origin skip stamping and forward to the continuation
  unchanged. Seals Stage and ProcessAsync so subclasses only implement
  GetCredentialAsync.

- ApiKeyAuthPolicy(ApiKeyCredential) — stamps credential.HeaderName with
  credential.Key when Scheme is null, or "<Scheme> <Key>" otherwise.

- BasicAuthPolicy(BasicCredential) — stamps Authorization with
  "Basic <base64(user:password)>", pre-computing the value at construction time.

Tests added (15 cases): stage assertion, header-value correctness with and
without scheme, custom header name, replace-existing-header, same-origin
re-stamp, and cross-origin withholding for both policies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduces BearerTokenAuthPolicy in src/Dexpace.Sdk.Core/Pipeline/Policies/:

- BearerTokenAuthPolicy(TokenCredential credential, params string[] scopes)
  creates exactly one AccessTokenCache at construction time, shared across all
  pipeline invocations. On each same-origin request it calls
  AccessTokenCache.GetAsync with the configured TokenRequestContext, then stamps
  Authorization: Bearer <token>. Cross-origin withholding is inherited from
  AuthorizationPolicy.

Tests added (7 cases): stage assertion, correct "Bearer <token>" value,
replace-existing-header, cache reused across two sends (credential called
exactly once), scopes forwarded to the credential, cross-origin withholding,
and same-origin re-stamp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AuthorizationPolicy previously only declined to re-stamp credentials on
cross-origin redirects, relying on RedirectPolicy to remove the header.
A consumer who composes auth without RedirectPolicy, or who disables
sensitive-header stripping there, would forward a stale credential to the
foreign origin unchanged.

The policy now actively removes the credential header it owns before
calling the continuation on a cross-origin hop. WithheldHeaderName is a
new abstract property on AuthorizationPolicy; each subclass returns the
header it stamps (credential.HeaderName for ApiKeyAuthPolicy;
HttpHeaderName.WellKnown.Authorization for Basic and Bearer). The
property is accessed only on the withhold branch, so it never triggers
token-cache or credential resolution.

New tests confirm the stale-header case for all three policies; the Bearer
test also asserts that the credential call count is unchanged by a
withheld cross-origin run. All 235 core tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@OmarAlJarrah OmarAlJarrah merged commit ab58bb8 into main Jun 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant