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
<PackageReference Include="Concierge.Auth.Client.Sessions" Version="3.0.0" />
<PackageVersion Include="Concierge.Auth.Client.Sessions" Version="3.0.0" />
<PackageReference Include="Concierge.Auth.Client.Sessions" />
paket add Concierge.Auth.Client.Sessions --version 3.0.0
#r "nuget: Concierge.Auth.Client.Sessions, 3.0.0"
#:package Concierge.Auth.Client.Sessions@3.0.0
#addin nuget:?package=Concierge.Auth.Client.Sessions&version=3.0.0
#tool nuget:?package=Concierge.Auth.Client.Sessions&version=3.0.0
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 | Versions 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. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.11)
- Microsoft.Extensions.Http (>= 10.0.11)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.11)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.