feat: add authentication credentials, token cache, and auth policies#8
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds authentication to the SDK — credentials, a token cache, and the policies that apply them — so requests can be authenticated through the pipeline.
Dexpace.Sdk.Core.Auth): a vendor-neutralTokenCredential(asyncGetTokenAsync+ sync bridge) withAccessToken/TokenRequestContext, plusApiKeyCredential(configurable header + optional scheme) andBasicCredential.AccessTokenCache: caches tokens per request context with proactive refresh (honoringRefreshOn), 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.ApiKeyAuthPolicy,BasicAuthPolicy,BearerTokenAuthPolicy(token via the cache), sharing anAuthorizationPolicybase that withholds and strips credentials on cross-origin hops — defense-in-depth, independent ofRedirectPolicy, so a credential can't leak to a foreign origin.No new package dependencies;
Corestays 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 Releaseclean on net8.0 + net10.0 (warnings-as-errors)dotnet format --verify-no-changescleandotnet test -c Releasepasses (252 tests: 235 core, 17 serialization)🤖 Generated with Claude Code