Concierge.Auth.Client.Sessions 3.0.0

dotnet add package Concierge.Auth.Client.Sessions --version 3.0.0
                    
NuGet\Install-Package Concierge.Auth.Client.Sessions -Version 3.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Concierge.Auth.Client.Sessions" Version="3.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Concierge.Auth.Client.Sessions" Version="3.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Concierge.Auth.Client.Sessions" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Concierge.Auth.Client.Sessions --version 3.0.0
                    
#r "nuget: Concierge.Auth.Client.Sessions, 3.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Concierge.Auth.Client.Sessions@3.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Concierge.Auth.Client.Sessions&version=3.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Concierge.Auth.Client.Sessions&version=3.0.0
                    
Install as a Cake Tool

Concierge.Auth.Client.Sessions

Client for AuthService's public human-facing session endpoints (contract §4.2-§4.4): login/request, login/verify, refresh, logout. Counterpart to Concierge.Auth.Client.AuthGuard, which validates a token someone else issued — this package is how a client service obtains one on a human's behalf (e.g. a BFF backing a browser app).

Dependencies — deliberately none of the rest of the SDK

This is the first Concierge.Auth.Client.* package with no database dependency and no ProjectReference on the base Concierge.Auth.Client package at all. These are AuthService's public human endpoints — none of the client-service machinery (EF Core persistence, credential store, at-rest encryption key) applies here, and taking that dependency "for consistency" would pull EF Core into a package that has nothing to persist. Result/Error/the options pattern are reused as a pattern, duplicated locally rather than shared via ProjectReference.

Also has no web-framework dependency — not even ASP.NET Core. Concierge.Auth.Client.AuthGuard is documented as "the only Concierge.Auth.Client.* package that references ASP.NET Core", and this package keeps that true: ISessionCookieCarrier is this package's own tiny cookie abstraction, and the host application supplies the adapter over its own HttpContext.

Three NuGet packages total: Microsoft.Extensions.Http, Microsoft.Extensions.DependencyInjection.Abstractions, Microsoft.Extensions.Options.ConfigurationExtensions — all already centrally versioned by the other packages in this SDK.

Install and register

services.AddConciergeAuthClientSessions(configuration); // binds "Concierge:AuthClient:Sessions"

// Pick ONE ISessionTokenStore. This package ships a cookie implementation; a header/bearer
// implementation is a few lines of your own ISessionTokenStore — contract §9 open item #16
// (httpOnly cookie vs localStorage+header) is DELIBERATELY left unresolved by this package.
services.AddConciergeAuthClientSessionsCookieStore();

// The cookie store needs a thin adapter over YOUR web framework's request/response cookies —
// this package has zero web-framework dependency, so you supply this, e.g. for ASP.NET Core:
//
//   public sealed class HttpContextSessionCookieCarrier(IHttpContextAccessor accessor) : ISessionCookieCarrier
//   {
//       public string? Read(string name) => accessor.HttpContext?.Request.Cookies[name];
//
//       public void Write(string name, string value, SessionCookieWriteOptions options) =>
//           accessor.HttpContext?.Response.Cookies.Append(name, value, new CookieOptions
//           {
//               HttpOnly = options.HttpOnly, Secure = options.Secure, Path = options.Path,
//               MaxAge = options.MaxAge,
//               SameSite = options.SameSite switch
//               {
//                   SessionCookieSameSite.Strict => SameSiteMode.Strict,
//                   SessionCookieSameSite.None => SameSiteMode.None,
//                   _ => SameSiteMode.Lax,
//               },
//           });
//
//       public void Delete(string name, string path) =>
//           accessor.HttpContext?.Response.Cookies.Delete(name, new CookieOptions { Path = path });
//   }
services.AddScoped<ISessionCookieCarrier, HttpContextSessionCookieCarrier>();
// appsettings.json
{
  "Concierge": {
    "AuthClient": {
      "Sessions": {
        "AuthServiceBaseUrl": "https://auth.thiso.vn",
        "RefreshCookiePath": "/api/v1/auth",   // default; only read by the cookie store
        "RefreshTokenCookieTtl": "7.00:00:00", // default 7 days, contract §3.3
        "HostOrigin": "https://mall.thiso.vn"  // optional — see "hostOrigin (FEAT-003)" below
      }
    }
  }
}

Use

var requestResult = await sessionClient.RequestLoginAsync("admin@thiso.vn", cancellationToken: cancellationToken);
if (requestResult.IsFailure)
{
    // requestResult.Error.Code — see gotchas below (domain / redirect / PollTokenMissing / unavailable)
}
// Show "check your email" — never log requestResult.Value.PollToken.

// Optional redirectUri (§4.2.1) — AuthService's emailed link verifies server-side and bounces
// back to your BFF callback with token query params instead of a client-side verify page.
await sessionClient.RequestLoginAsync(
    "admin@thiso.vn",
    redirectUri: "https://mall.thiso.vn/login/callback",
    cancellationToken: cancellationToken);

var verifyResult = await sessionClient.VerifyLoginAsync(loginTokenFromLink, cancellationToken);
if (verifyResult.IsFailure)
{
    // verifyResult.Error.Code is one of:
    //   AUTH_LOGIN_TOKEN_INVALID  — not-found/expired/consumed, indistinguishable by design
    //   AUTH_ACCOUNT_SUSPENDED / AUTH_ACCOUNT_OFFBOARDED
    //   SESSION_LOGIN_VERIFY_UNAVAILABLE — AuthService unreachable, safe to retry
}

// Redirect callback (§4.2.5/§4.2.6) — parse the query string your BFF action receives after
// AuthService bounces the browser back. Credential is persisted before this returns.
var queryParams = Request.Query.ToDictionary(
    pair => pair.Key,
    pair => (string?)pair.Value.ToString());
var redirectResult = await sessionClient.CompleteRedirectLoginAsync(queryParams, cancellationToken);
if (redirectResult.IsFailure)
{
    // redirectResult.Error.Code is one of:
    //   SESSION_LOGIN_REQUIRED — ?error=login_required (silent SSO / expired SSO marker)
    //   SESSION_REDIRECT_LOGIN_CALLBACK_INVALID — missing token or malformed lifetime fields
}

// Cross-client silent SSO (§4.2.6) — top-level redirect the browser to AuthService; on return,
// handle the callback with CompleteRedirectLoginAsync above.
var silentLoginUrl = sessionClient.BuildSilentLoginUrl("https://mall.thiso.vn/login/callback");
// In your BFF route handler: return Results.Redirect(silentLoginUrl.ToString());

var outcome = await sessionClient.RefreshAsync(cancellationToken);
switch (outcome)
{
    case RefreshOutcome.Renewed renewed:
        // renewed.Tokens is the new pair (already persisted to the store)
        break;
    case RefreshOutcome.ReAuthenticationRequired: // AUTH_REFRESH_INVALID or _EXPIRED
        // store already cleared — send the user back to login
        break;
    case RefreshOutcome.SessionCompromised: // AUTH_REFRESH_REUSED
        // store already cleared — surface this LOUDLY, this was theft, not expiry
        break;
    case RefreshOutcome.TransientFailure:
        // the ONLY case where tokens are untouched — safe to retry later
        break;
}

await sessionClient.LogoutAsync(cancellationToken); // always clears the local store

Cross-device login (§4.2.9)

When a user requests a magic link on device A (PC browser) and clicks it on device B (phone), device A can authenticate by polling with the pollToken AuthService returns on login/request (auth-v1.11.0+).

// 1. Request the link — 202 returns a PendingMagicLinkLogin (pollToken + initial retryAfterMs).
var pendingResult = await sessionClient.RequestLoginAsync("admin@thiso.vn", cancellationToken: ct);
if (pendingResult.IsFailure) { /* handle */ }
var pending = pendingResult.Value;
// Show "check your email" — never log pending.PollToken or put it in a URL.

// 2. Poll until ready (§4.2.9.4) — waits pending.RetryAfterMs before the first poll, then honours
//    each server retryAfterMs. Credential is persisted before return.
var sessionResult = await sessionClient.PollLoginUntilReadyAsync(pending, cancellationToken: ct);
// Or poll manually and pattern-match LoginPollOutcome.Pending / Ready / Consumed / Expired.

Breaking in 3.0.0: deviceEvidence on login/request and login/poll is removed. Use RequestLoginAsync → PendingMagicLinkLogin → PollLoginUntilReadyAsync(pending) against AuthService auth-v1.11.0+. Older AuthService builds that return 202 without pollToken surface SESSION_POLL_TOKEN_MISSING.

ready → consumed is single-use and not idempotent (§4.2.9.6). On ready, the credential is persisted before the result is returned — same ordering as CompleteRedirectLoginAsync. If you discard a ready response after the fact, the session is orphaned; do not poll again expecting another ready.

AUTH_LOGIN_POLL_INVALID collapses every pre-state failure — unknown handle, creator-IP mismatch, invalid poll token — into one code (§4.2.9.4). Do not try to distinguish IP mismatch from a missing handle; AuthService deliberately reveals nothing. After the gates pass, consumed and expired are distinguished so device A can stop spinning.

hostOrigin (FEAT-003, T-0415)

AuthService resolves which registered client service a sign-in/refresh request is for from a hostOrigin field — its per-service access gate (contract §12b) cannot run without it. Set ConciergeSessionsOptions.HostOrigin to this consuming app's own public browser origin (scheme://host[:port], no path — validated at startup) and it is sent automatically on:

  • PollLoginAsync / PollLoginUntilReadyAsync (login/poll)
  • VerifyLoginAsync (login/verify)
  • RefreshAsync — both the session lane (session/refresh) and the deprecated JWT lane (refresh)

A per-call hostOrigin parameter on PollLoginAsync, PollLoginUntilReadyAsync and VerifyLoginAsync overrides the option for that one call — for a BFF that serves more than one origin. Leaving the option unset (and passing no override) omits hostOrigin from every request, byte-identical to this package's behaviour before the option existed.

RequestLoginAsync never sends hostOrigin, on purpose. login/request has no such field — there, the redirectUri origin is what identifies the service (contract §5). Do not add it there.

Minimum AuthService version. Sending hostOrigin on session/refresh or the JWT-lane refresh requires an AuthService build at or above auth-v1.9.1 (T-0423) — an older server rejects the field with 400. Sending it on login/verify additionally requires the build carrying T-0412 — an older server rejects unknown fields on that endpoint with 400. Do not set HostOrigin against a server older than auth-v1.9.1.

Legacy local credential metadata (⟨E8⟩). RefreshAsync does not send poll binding fields on session/refresh. Older sessions may still carry a local hash on the credential from pre-3.0 SDK versions; it is never sent on the wire again after login.

Gotchas

Refresh-reuse is terminal, not retryable, and it is enforced in the type system. RefreshAsync returns a closed RefreshOutcome — Renewed / ReAuthenticationRequired / SessionCompromised / TransientFailure — instead of a Result. There is no IsSuccess/IsFailure on RefreshOutcome, so the naive retry idiom every other Result-based client in this SDK allows —

while (outcome.IsFailure) { outcome = await sessionClient.RefreshAsync(ct); } // does not compile

— cannot be written by accident. Contract §3.3: presenting an already-rotated refresh token means it was stolen; AuthService has already revoked the whole rotation chain and deny-listed the access jtis. SessionCompromised and ReAuthenticationRequired both clear the local ISessionTokenStore before returning; only TransientFailure leaves tokens untouched and is safe to retry. (This project has already been bitten once by blind-retrying a 401 — see Concierge.Auth.Client.Secrets' SecretRotationClient, contract §10 invariant 8 — where a code comment was the only guard rail. This time it is a compile error.)

No user-enumeration branch, ever. For a valid 202 with pollToken, RequestLoginAsync returns the same success shape for a registered, unregistered, suspended, and offboarded address — identically (contract §4.2.1, assumption #45). There is no AccountNotFound/UserNotFound/EmailNotRegistered anywhere in this package, and there must never be one added — that is precisely the enumeration oracle the contract removed. The one legitimate exception is AUTH_EMAIL_DOMAIN_NOT_ALLOWED (403) — a domain is organizational metadata, not secret (assumption #223) — and it is surfaced as a distinct Result.Failure. An AuthService older than auth-v1.11.0 that returns 202 without pollToken surfaces SESSION_POLL_TOKEN_MISSING instead. A rate-limited request also returns success here when the body includes a poll token — do not add retry logic expecting a 429 on login/request.

AUTH_REDIRECT_URI_NOT_ALLOWED (400, assumption #364/#365) is also surfaced distinctly when a redirectUri is passed — it says something about the caller's own registered origin, not about the target address, so it carries no enumeration risk. Register the origin in AuthService's auth.redirect_origins (optionally scoped to your client_service_id via its admin API) before retrying; retrying the same call unchanged will fail identically.

login/verify's AUTH_LOGIN_TOKEN_INVALID is one code for three states. Not-found, expired, and already-consumed are deliberately collapsed so a caller cannot fingerprint token state — do not try to split it. AUTH_ACCOUNT_SUSPENDED/AUTH_ACCOUNT_OFFBOARDED are separate 403s, checked only after the token validates.

Logout clears the local store even if the HTTP call fails. A user who clicked "log out" must not stay logged in locally because the network blipped or AuthService errored. LogoutAsync is also harmless to call twice — the second call finds no stored tokens and skips the HTTP round trip.

Cookie path-scoping matters. The shipped CookieSessionTokenStore writes the refresh cookie scoped to RefreshCookiePath (default /api/v1/auth), not / — narrowing which requests transmit the long-lived credential. ClearAsync deletes all three cookies (access, refresh, CSRF) at the same paths they were written with; a cookie deleted at the wrong path is silently ignored by the browser and survives — a logout that looks like it worked and did not. The CSRF cookie is written with HttpOnly = false on purpose — JS must read it to echo X-CSRF-Token — while access and refresh stay HttpOnly. ReplaceAsync (used on refresh) never rotates the CSRF cookie; only StoreAsync (used on login) does — re-issuing it on every refresh would invalidate a CSRF token the browser tab still has cached mid-session.

Open item #16 is not resolved here, and is not this package's call. The contract itself leans header/localStorage over cookies, for split-deployment reasons (AuthService on a different host than the client service). The shipped store is a cookie implementation because it is the one with real, hard-won details worth preserving in code. Ship an ISessionTokenStore implementing a header/bearer scheme when you need one — nothing in ISessionClient, RefreshOutcome, or SessionTokens assumes cookies; only CookieSessionTokenStore does.

Out of scope here

GET/PATCH /api/v1/auth/me (§4.5/§4.8, human self-service profile) and the client-service profile surface (§10.7) — see Concierge.Auth.Client.Profiles. Verifying a token someone else issued — see Concierge.Auth.Client.AuthGuard. Client-service (machine) credentials — see Concierge.Auth.Client.Secrets.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.0.0 85 9/28/2026
2.4.0 94 9/16/2026
2.3.0 319 9/9/2026
2.1.0 178 9/4/2026
2.0.0 110 8/24/2026
1.0.0 119 8/17/2026