Authentication

Wallow uses a sign-in ticket pattern to bridge the React auth app (apps/wallow-auth) with the API's cookie authentication system. The auth UI verifies credentials through a JSON API call, but landing the user in an authenticated session needs a top-level browser navigation. Instead of trying to set the cookie on that background call, the API issues an encrypted, short-lived ticket that the browser exchanges for a real auth cookie via a full-page GET.


Why Tickets Exist

The auth app is a client-side React application. It authenticates by calling the login API over the app's same-origin proxy with fetch/XHR, which returns JSON (containing the ticket) — not an auth cookie.

Completing sign-in requires two things that a background fetch cannot do: set an HttpOnly cookie the browser will actually send on subsequent requests, and resume the paused OIDC authorization at returnUrl. Both need a real top-level navigation. The sign-in ticket lets the app hand off from its fetch-based login to a direct browser GET on the API's exchange-ticket endpoint, which sets the cookie and redirects into returnUrl.


Ticket Lifecycle

apps/wallow-auth (React)          Wallow.Api (AccountController)
─────────────────────────────     ──────────────────────────────────
POST /v1/identity/auth/login ───► Validate credentials
                             ◄─── Return { signInTicket: "<token>" }

Navigate browser to:
  /v1/identity/auth/exchange-ticket
  ?ticket=<token>
  &returnUrl=<url>
  &clientId=<id>             ───► 1. Decrypt + validate ticket
                                  2. SET NX ticket:used:{jti} in Valkey (90s TTL)
                                  3. If key already existed → reject (replay)
                                  4. SignInAsync → issue browser auth cookie
                             ◄─── 302 Redirect to returnUrl

Creation

CreateSignInTicket runs in AccountController after credentials are successfully verified:

private const string TicketPurpose = "SignInTicket";
private static readonly TimeSpan _ticketLifetime = TimeSpan.FromSeconds(60);

private string CreateSignInTicket(string email, bool rememberMe)
{
    ITimeLimitedDataProtector protector = dataProtectionProvider
        .CreateProtector(TicketPurpose)
        .ToTimeLimitedDataProtector();

    SignInTicketPayload payload = new(email, rememberMe, Guid.NewGuid());
    string json = JsonSerializer.Serialize(payload);
    return protector.Protect(json, _ticketLifetime);
}

private sealed record SignInTicketPayload(string Email, bool RememberMe, Guid Jti);
  • Jti (JWT ID): a Guid.NewGuid() unique identifier embedded in every ticket. Used as the Valkey key for single-use enforcement.
  • TTL: 60 seconds. After this window the data protector refuses to decrypt the token.
  • Purpose: "SignInTicket" — ASP.NET Core Data Protection uses the purpose string as part of the encryption key derivation. A ticket encrypted for "SignInTicket" cannot be decrypted by any other protector purpose (e.g., "ExternalLogin").

Data Protection

ASP.NET Core's ITimeLimitedDataProtector wraps the standard IDataProtector with an expiry timestamp embedded in the ciphertext. The protector:

  1. Encrypts the JSON payload using AES-256-CBC with HMACSHA256 authentication.
  2. Encodes the expiry as part of the protected payload.
  3. Rejects decryption attempts after the expiry has passed (throws CryptographicException).

The encryption keys are managed by the Data Protection system (stored in the configured key ring — typically a shared volume or Valkey in production). All API instances share the same key ring, so any instance can validate a ticket issued by another.

Exchange and Single-Use Enforcement

ExchangeTicket in AccountController handles the browser's direct GET request:

[HttpGet("exchange-ticket")]
[AllowAnonymous]
public async Task<IActionResult> ExchangeTicket(
    [FromQuery] string ticket,
    [FromQuery] string? returnUrl,
    [FromQuery] string? clientId = null)
{
    SignInTicketPayload? payload = ValidateSignInTicket(ticket);
    if (payload is null)
    {
        return BadRequest(new { succeeded = false, error = "invalid_or_expired_ticket" });
    }

    // Replay prevention: each ticket can only be exchanged once
    IDatabase redisDb = redis.GetDatabase();
    bool wasSet = await redisDb.StringSetAsync(
        $"ticket:used:{payload.Jti}", "1", TimeSpan.FromSeconds(90), false, When.NotExists);
    if (!wasSet)
    {
        return Unauthorized(new { succeeded = false, error = "ticket_already_used" });
    }

    WallowUser? user = await signInManager.UserManager.FindByEmailAsync(payload.Email);
    if (user is null)
    {
        return BadRequest(new { succeeded = false, error = "invalid_or_expired_ticket" });
    }

    await signInManager.SignInAsync(user, isPersistent: payload.RememberMe);
    // ... redirect
}

The Valkey SET NX EX call is atomic:

  • NX (Not eXists): only sets the key if it does not already exist.
  • EX 90 (expiry): the key auto-expires after 90 seconds, slightly longer than the ticket TTL, to cover clock skew between the issuance time and exchange time.
  • If wasSet is false, the key was already present — the ticket has already been used. The request is rejected as a replay.

This guarantees that even if an attacker intercepts the ticket URL (e.g., from browser history or a proxy log), replaying it returns 401 ticket_already_used.


When Tickets Are Issued

Every CreateSignInTicket call site in AccountController is listed below. Endpoints are relative to the controller's v{version:apiVersion}/identity/auth route, so POST /login is served at /v1/identity/auth/login.

Scenario Endpoint Notes
Password login, no MFA POST /login Standard flow
Password login, org MFA grace period active POST /login Ticket issued; enrollment banner shown
MFA challenge passed POST /mfa/verify Issued after TOTP or backup code verification
Magic link verified GET /passwordless/magic-link/verify Returns { succeeded, email, signInTicket }
OTP verified POST /passwordless/otp/verify Returns { succeeded, email, signInTicket }

Passwordless flows go through the same ticket exchange as password login — the response shape is identical, and the auth app navigates to exchange-ticket exactly as it does after a password login.

Tickets are not issued for external OAuth provider logins: that callback is already a direct browser GET, so the API sets the cookie immediately and no hand-off is needed.


Client-Side Exchange (React)

After receiving a successful login response containing a ticket, the login screen builds the exchange URL through the SDK and assigns globalThis.location.href to it, forcing a full-page navigation:

import { buildExchangeTicketUrl } from "@bc-solutions-coder/sdk";

const SAME_ORIGIN_BASE: string = BASE_PATH;

if (result.signInTicket) {
  globalThis.location.href = buildExchangeTicketUrl(
    SAME_ORIGIN_BASE,
    result.signInTicket,
    returnUrl,
    clientId,
  );
  return;
}

The fourth argument is optional. Passing it scopes the endpoint's returnUrl allow-list check to that client; a nullish or blank value omits the query parameter. MfaChallengeForm.tsx passes its scopedClientId here for exactly that reason.

Assigning location.href (rather than a client-side router navigation) is required so the browser actually sends the GET to exchange-ticket and receives the Set-Cookie response header. Because the endpoint is reached through the auth app's same-origin proxy, the cookie is set on the correct origin. The MFA challenge screen performs the same exchange after a TOTP or backup-code verification.


Security Properties

Property Mechanism
Confidentiality AES-256-CBC encryption via Data Protection
Integrity HMACSHA256 authentication tag
Expiry 60-second TTL enforced by ITimeLimitedDataProtector
Single-use Valkey SET NX — atomic, guaranteed at most one exchange
Purpose isolation "SignInTicket" purpose string prevents cross-purpose decryption
Replay TTL headroom Valkey key expires at 90s (>60s ticket TTL) to handle clock skew

Organization context

Once the cookie exists, the resumed OIDC authorization decides which organization the issued token belongs to. There is one code path for every client:

Client Organization Enrollment policy
Third-party, bound to an organization The bound one. An organization parameter naming any other organization is invalid_request. Always runs.
First-party, organization=<guid> passed The hinted one. Runs exactly as for a bound client.
First-party, no hint The user's single membership, or none when they hold several or zero. Not run; the org-less token reaches only [AllowWithoutOrganization] endpoints.

When the policy refuses a user on a bound client — pending, suspended, denied, or not a member — the transaction ends at the RP's redirect_uri with error=access_denied and error_description ∈ membership_pending | membership_suspended | membership_denied | not_a_member; a pending outcome still records the membership request. Only an unverified email stays on the auth host, because that is the user's to fix, not the RP's.

Every sign-in links its tokens to an OpenIddict authorization that records the organization (org_id in the authorization's properties): a permanent consent authorization for a third-party client, an ad-hoc one for a first-party sign-in with an organization. Revoking a membership therefore revokes the tokens that named that organization — whichever client issued them — alongside the bound-client tokens.

Switching organization is a fresh authorization with the hint: the SDK's loginRedirect(returnTo, { organization }) builds the link, the SSO cookie makes the re-authorize silent, and wallow-web's My organizations page is the picker. The auth host has none.


Key Files

File Role
api/src/Modules/Identity/Wallow.Identity.Api/Controllers/AccountController.cs CreateSignInTicket, ValidateSignInTicket, ExchangeTicket endpoint
apps/wallow-auth/src/features/login/components/LoginScreen.tsx Ticket exchange navigation logic
apps/wallow-auth/src/features/mfa-challenge/components/MfaChallengeForm.tsx Ticket exchange after MFA verification
apps/wallow-auth/src/features/login/auth-result.ts Parses the signInTicket field from the login response
apps/wallow-auth/src/features/login/magic-link-result.ts, otp-result.ts The passwordless equivalents

  • Authorization — what the cookie's claims are allowed to do
  • Caching — the Valkey instance the replay guard writes to
  • BFF Pattern — how a frontend consumes the session
  • External Auth — the OAuth provider flows that skip the ticket