HTTP API Toolkit HTTP API Toolkit
HTTP API Toolkit HTTP API Toolkit
DocFX + Singulink = ♥

Search Results for

    Sign-In Codes and Device Linking

    This guide covers the building blocks for sign-in flows that span several requests or devices: the short-lived store, one-time sign-in codes bound to PKCE, device linking, and short codes that users type. The library provides the logic and the data contracts of the standard steps; your service keeps its own endpoints, its contracts for anything that carries its own data, its wording and its authorization rules.

    Registering the services

    Register the parts you use:

    builder.Services.AddSignInCodes();    // sign-in codes and the short-lived store
    builder.Services.AddDeviceLinks();    // device links, plus everything sign-in codes need
    

    AddShortLivedStore registers just the short-lived store. Each method takes an optional callback to configure its options (see Options). The registration methods, the short-lived store and its helpers are in the Singulink.Net.Http.Api.Service namespace, like the toolkit's other registration methods. Sign-in codes, device links and short codes are in Singulink.Net.Http.Api.Service.SignIn, and the data contracts that the endpoints take and return are in Singulink.Net.Http.Api.DataContracts (see Data contracts).

    Data contracts

    The shared Singulink.Net.Http.Api package has the data contracts of the standard steps, which endpoints take and return as they are and clients read and write with the same types:

    Contract Step
    SignInCode A one-time sign-in code that a verifying step returns.
    RedeemSignInCodeCommand Redeeming a sign-in code for a session.
    BeginPasskeySignInCommand and FinishPasskeySignInCommand Beginning and finishing a passkey sign-in (see Passkeys).
    FinishPasskeyCeremonyCommand Finishing a passkey registration or verification (see Passkeys).
    StartDeviceLinkCommand and DeviceLinkStart Starting a device link.
    ApproveDeviceLinkCommand and PendingDeviceLink Looking up the details of a device waiting to be linked by its code, and approving it.
    CheckDeviceLinkCommand and DeviceLinkStatus Checking the status of a device link.

    Like the app's own contracts, they are listed in the app's JsonSerializerContext on both sides: a client built with a type info resolver, and a trimmed or Native AOT service, has no reflection fallback and throws a NotSupportedException for a type that is not listed.

    The Short-Lived Store

    IShortLivedStore keeps short-lived state between requests: the toolkit keeps its sign-in codes, device links, passkey ceremonies and rate limit counts there, and your service can keep the state of its own flows, such as a setup ticket. Values are strings that expire after a lifetime.

    ShortLivedState<T> keeps states of one type as JSON under a key prefix of your own, each for the same lifetime, with the JsonTypeInfo<T> of a source-generated serializer context so that trimming and AOT keep working. The prefix keeps each kind of state apart, so an ID presented to the wrong step of a flow finds nothing. AddAsync stores a state under a new random ID and returns the ID, for the client to present in the next step. The ID is 256 bits of randomness, so it also serves as the secret that protects the state. A class derived from it can be registered as a service:

    public sealed record SetupTicket(string UserId);
    
    [JsonSerializable(typeof(SetupTicket))]
    internal sealed partial class AppJsonContext : JsonSerializerContext;
    
    public sealed class SetupTickets(IShortLivedStore store, ILogger<SetupTickets> logger)
        : ShortLivedState<SetupTicket>(store, "my-service:setup-ticket:", AppJsonContext.Default.SetupTicket, TimeSpan.FromMinutes(15), logger);
    
    // Step 1: hand out a ticket.
    string ticket = await setupTickets.AddAsync(new SetupTicket(userId));
    
    // Step 2: use it up.
    var setup = await setupTickets.TakeAsync(command.Ticket) ?? throw new UnauthorizedApiException("The setup expired. Please start again.");
    

    TakeAsync returns a state at most once, even when concurrent requests present the same ID, GetAsync reads a state without using it up, and RemoveAsync removes one without reading it. For an ID that does not have the form of one (see ShortLivedId), they return null or remove nothing, without a lookup. A stored value that cannot be read as the state, for example one that an earlier version of the service stored during a rolling deployment, counts as missing and is logged as a warning to the logger, if one is given.

    ShortLivedStoreExtensions has the same operations with the prefix and type info passed to each call, and TryAddAsync, which adds a state under an ID of your choosing, such as a user ID, only if it is free.

    The store itself has five operations on whole keys (add if free, get, take, remove and count), which together cover one-time use, approve-once steps and attempt limits. The toolkit's keys start with httpapi:, so keys with a prefix of the app's own never clash with them.

    Counting attempts

    AcquirePermitAsync counts attempts in fixed windows and returns a FixedWindowPermit: whether a permit was acquired and, if not, how long until the window ends. Each key within a prefix has a count of its own, kept in the store, so a limit holds across all the servers that share it. Windows are measured by the clock that is passed in, here the registered TimeProvider:

    string email = command.Email.Trim().ToUpperInvariant();
    var permit = await store.AcquirePermitAsync("my-service:setup-emails:", email, permitLimit: 3, window: TimeSpan.FromHours(1), timeProvider);
    
    if (!permit.IsAcquired)
        throw new TooManyRequestsApiException("Too many emails were sent to this address. Please try again later.") { RetryAfter = permit.RetryAfter };
    

    Windows start at fixed times on the clock (a one-hour window starts at every full hour), so every server counts the same window. Every attempt is counted, including the ones that acquire no permit. The store only receives a hash of the key, so a key such as an email address is not kept in it. Keys are compared exactly, so the sample upper-cases the address: [email protected] and [email protected] would otherwise be counted apart, each with a limit of its own.

    The default store, MemoryShortLivedStore, keeps everything in the memory of its server, and a restart only interrupts the flows in progress. A store backed by shared storage, such as Redis or a database table, shares the state between the servers of a service; see Running Multiple Servers.

    The memory store's size

    The memory store holds about 256 MiB of values and counters, set with MaxSize in bytes:

    builder.Services.AddShortLivedStore(options => options.MaxSize = 64 * 1024 * 1024);
    

    The size is an estimate: each entry counts 2 bytes per character of its key and value, plus about 120 bytes. When adding a value or starting a counter would go over it, expired entries are removed first. If that does not make enough room, the operation throws a ServiceUnavailableApiException with the error code short-lived-store-full and a Retry-After of a minute, so the request receives a 503 Service Unavailable error, and a warning is logged, at most once a minute. A rate limit that cannot count a request refuses it with the same error. Reading, taking and removing values, and changing counters that already exist, always work.

    Sign-In Codes

    A sign-in code is a one-time code that a client exchanges for a session. Ending every way of signing in with a code has two benefits:

    • The step that verifies the user (a passkey, a password, an approved device link) does not create the session itself, so the client can ask questions in between, such as whether this is the user's own device and should stay signed in.
    • Each code is bound to a PKCE code challenge, so only the client that holds the matching code verifier can redeem it, wherever the code travels (see PKCE).

    The client creates a Pkce for every sign-in and sends its challenge in the request that starts the sign-in, so that everything the sign-in hands back is bound to it. When verifying the user takes a single request, as with a password, that request carries the challenge:

    var pkce = Pkce.Create();
    var result = await apiClient.SignInWithPasswordAsync(new(email, password, pkce.Challenge));
    
    bool persistent = await AskWhetherToStaySignedInAsync();
    await apiClient.RedeemSignInCodeAsync(new(result.Code, pkce.Verifier, persistent));
    

    When it takes more than one, as with a passkey, the first request carries the challenge and the sign-in code is bound to the challenge that the flow already holds; see Signing in with a passkey and Device Linking.

    On the service, the verifying endpoint creates a code with CreateAsync and returns it as a SignInCode, and a redeem endpoint signs in with SignInCodes.SignInAsync, which takes the RedeemSignInCodeCommand. The password sign-in command is the app's own:

    static async Task<SignInCode> SignInWithPasswordAsync(PasswordSignInCommand command, SignInCodes signInCodes, AppDbContext db)
    {
        var user = await VerifyPasswordAsync(db, command.Email, command.Password);   // throws UnauthorizedApiException on failure
        return new SignInCode(await signInCodes.CreateAsync(user.Id.ToString(CultureInfo.InvariantCulture), command.CodeChallenge));
    }
    
    static async Task<CurrentSessionInfo> RedeemSignInCodeAsync(
        RedeemSignInCodeCommand command,
        SessionContext<SessionToken> sessionContext,
        SignInCodes signInCodes,
        AppDbContext db)
    {
        var token = await signInCodes.SignInAsync(sessionContext, command, async (redeemed, info) => {
            var user = await db.Users.SingleAsync(u => u.Id == int.Parse(redeemed.UserId, CultureInfo.InvariantCulture));
            EnsureCanSignIn(user);   // throws if the account was disabled since the code was created
    
            return await CreateSessionAsync(db, user, info);   // adds the session record and returns its token, as in Session Handling
        });
    
        return new CurrentSessionInfo(token.UserId);
    }
    

    The rules are:

    • CreateAsync throws BadRequestApiException for a malformed challenge. A valid challenge is 43 base64url characters: the SHA-256 hash of the verifier (the S256 method). To check one up front with the same rules, for example when a sign-in begins, call EnsureValid, which throws the same exception, or IsValid.
    • A code can be redeemed once, within Lifetime (10 minutes by default).
    • A wrong verifier uses the code up too, so a code that reached the wrong party can be tried only once.
    • RedeemAsync returns a RedeemedSignInCode with the user ID, or null when the code is invalid. SignInAsync throws UnauthorizedApiException (sign-in-code-invalid) instead, and only calls your function for a valid code, with the redeemed code.
    • SignInAsync checks the request's origin before it redeems the code, as signing in does (see Session Handling), so a request from an origin that is not allowed gets a 403 Forbidden error (origin-blocked) and leaves the code unused.

    The exceptions carry error codes from SignInErrorCodes, so clients can tell the failures apart:

    Error code Exception Meaning
    sign-in-code-invalid Unauthorized The sign-in code is unknown, expired or used up, or the verifier is wrong.
    code-challenge-invalid Bad request The code challenge is missing or malformed.
    device-link-too-many-wrong-codes Too many requests The user entered too many wrong device link codes (see Device Linking).
    device-link-expired Unauthorized The device link is unknown or expired, or the poll secret is wrong (see Device Linking).

    The user was verified when the code was created, which can be up to the code lifetime earlier, so the session creation function above checks that the account can still sign in. A code created before a password reset or before the user ended their other sessions still signs in within its lifetime, unless the session creation function refuses it, for example by comparing a value that the code carries (see below) with the current one.

    A code can also carry a value of the app's, which CreateAsync takes as appData and redeeming returns as RedeemedSignInCode.AppData, so the session creation function receives it with the user ID. The value is kept in the short-lived store with the code. The code that a device link issues carries the value passed to ApproveAsync, and the code for a passkey the value passed to CreateAsync.

    User IDs are strings, so that the library works with any ID type. The samples format them with the invariant culture.

    Device Linking

    Device linking signs in a new device with the approval of a device where the user is already signed in. It covers devices that cannot use the user's other ways in, such as a computer without the user's passkey, or a command-line tool. The flow has four steps, each an endpoint of your service:

    1. The new device creates a Pkce, starts a link with the challenge, and shows the returned code.
    2. On the signed-in device, the user types the code and sees the new device's details: its name, IP address, approximate location and when the link was started.
    3. The user checks that it is the device in front of them and approves it.
    4. Meanwhile the new device polls the link's status. Once the link is approved, the status contains a sign-in code bound to the challenge the link was started with, which the new device redeems with its verifier like any other sign-in code.

    The user starts the flow and types the code, so an attacker would have to talk them into typing the attacker's code. Showing the device details before approval is the defense against that.

    The endpoints for the new device take no session, and take and return the standard data contracts:

    static Task<DeviceLinkStart> StartDeviceLinkAsync(StartDeviceLinkCommand command, DeviceLinks deviceLinks, HttpContext httpContext)
    {
        // The location from Cloudflare's headers (see Cloudflare), or from another source, such as a geolocation database.
        string? location = httpContext.GetClientLocation()?.ToString();
        return deviceLinks.StartAsync(command.CodeChallenge, httpContext.GetClientDevice(), httpContext.GetClientIpAddress(), location);
    }
    
    static Task<DeviceLinkStatus> CheckDeviceLinkAsync(CheckDeviceLinkCommand command, DeviceLinks deviceLinks)
    {
        return deviceLinks.CheckAsync(command.Id, command.PollSecret);
    }
    

    GetClientDevice and GetClientIpAddress get the device and the IP address that session handling records for a session, and GetClientLocation the location that Cloudflare sends (see Cloudflare).

    The endpoints for the signed-in device take the code that the user typed in an ApproveDeviceLinkCommand, validate the session, and apply any rules about who may add devices:

    static async Task<PendingDeviceLink> LookUpDeviceLinkAsync(
        ApproveDeviceLinkCommand command,
        [SessionAccess(SessionAccessOptions.ForceValidate)] SessionToken token,
        DeviceLinks deviceLinks)
    {
        return await deviceLinks.FindAsync(command.Code, token) ??
            throw new NotFoundApiException("No device is waiting with this code. Check the code and try again.");
    }
    
    static async Task ApproveDeviceLinkAsync(
        ApproveDeviceLinkCommand command,
        [SessionAccess(SessionAccessOptions.ForceValidate)] SessionToken token,
        DeviceLinks deviceLinks,
        AccountEmails emails)
    {
        var approval = await deviceLinks.ApproveAsync(command.Code, token);
    
        switch (approval.Outcome)
        {
            case DeviceLinkApprovalOutcome.NotFound:
                throw new NotFoundApiException("No device is waiting with this code. Check the code and try again.");
            case DeviceLinkApprovalOutcome.AlreadyApproved:
                throw new ConflictApiException("This device was already approved.");
        }
    
        await emails.SendDeviceLinkedAsync(token.UserId, approval.Link!);   // the details of the device that was approved
    }
    

    FindAsync and ApproveAsync take the session token of the signed-in user, whose UserId identifies the user, or the user ID itself. The approval's Link has the details of the device that was approved, which IsApproved tells is not null.

    On the new device, DeviceLinkWaiter in Singulink.Net.Http.Api.Client checks the link's status until the link is approved, and returns the sign-in code that the status then has:

    var pkce = Pkce.Create();
    var waiter = new DeviceLinkWaiter(await apiClient.RequestDeviceLinkAsync(new(pkce.Challenge)));
    ShowCode(waiter.Link.Code);
    
    try
    {
        string code = await waiter.WaitForApprovalAsync(apiClient.GetDeviceLinkStatusAsync, reachable => ShowOffline(!reachable), cancellationToken);
        await apiClient.RedeemSignInCodeAsync(new(code, pkce.Verifier, isPersistent: false));
    }
    catch (UnauthorizedApiException ex) when (ex.ErrorCode == SignInErrorCodes.DeviceLinkExpired)
    {
        ShowMessage("The code has expired. Start again to get a new one.");
    }
    

    RequestDeviceLinkAsync, GetDeviceLinkStatusAsync and RedeemSignInCodeAsync stand for the client's methods for the endpoints above and the one that redeems sign-in codes (see Signing In and Restoring a Session). GetDeviceLinkStatusAsync takes a CheckDeviceLinkCommand and a cancellation token, and returns the DeviceLinkStatus, so the waiter can call it as it is. The waiter checks the status every PollInterval (2 seconds by default), and the service decides when the link has expired: the check of an expired link throws an UnauthorizedApiException with the device-link-expired error code (DeviceLinkExpired), which ends the wait. A check that fails for a reason that may not last (see ApiFailures.IsTransient), such as while the service can't be reached, is made again, and reported to the optional callback, until the link's Lifetime has passed, counted from when the waiter was created, which doesn't depend on the device's clock. The check's exception is then thrown. RemainingLifetime is the part of the lifetime that is left.

    The rules are:

    • A link waits for approval for Lifetime (10 minutes by default). StartAsync throws BadRequestApiException for a malformed challenge.
    • FindAsync and ApproveAsync accept the code as typed (see Short Codes).
    • Wrong codes count against the user typing them. After 5 wrong codes, both methods throw TooManyRequestsApiException for that user, so a signed-in account cannot be used to guess codes, not even with many requests at once. The count is forgotten once the link lifetime passes without a lookup by the user; lookups that are refused do not count, so the exception's RetryAfter is the link lifetime. Input that cannot be a code is not counted.
    • A link can be approved once, even when several users approve it at the same moment on different servers.
    • CheckAsync checks the poll secret in fixed time and throws UnauthorizedApiException (device-link-expired) for an unknown or expired link or a wrong secret. Once the link is approved, every check gets the same sign-in code, so the new device can simply check again if a response does not reach it; the code can be redeemed once, and only with the new device's verifier. An approval made in the last seconds of the link can still be collected for a minute after the code expires.
    • PollAsync does the same, and returns null instead of throwing. Its result also has the ID of the user who approved the link (ApproverUserId).

    Links, wrong code counts and the sign-in codes are kept in the registered IShortLivedStore, so each step can run on a different server when the store is shared.

    Short Codes

    ShortCode handles codes that users read on one screen and type on another. Device links use it, and so can the app's own codes, such as emailed setup codes. A code has 8 characters from Crockford's base 32 (about 10^12 codes), which avoids the easily confused letters I, L, O and U:

    string code = ShortCode.Generate();                  // "K7PXM3QR"
    string display = ShortCode.Format(code);             // "K7PX-M3QR"
    string? typed = ShortCode.Normalize(" k7px m3qr ");  // "K7PXM3QR", or null if the input cannot be a code
    

    Normalize forgives typing: case does not matter, O is read as 0, I and L are read as 1, and spaces and dashes are ignored, so normalized codes compare equal however the code was typed.

    A short code is far easier to guess than a random key: whatever it unlocks is exposed for as long as the code is valid, to as many wrong attempts as the app allows. There are only about 10^12 codes, so anyone with a copy of the database could find the code of a plain hash in minutes by hashing every code. A keyed hash, whose key is kept outside the database, such as in an environment variable or a key vault, tells them nothing without the key.

    ComputeHash computes the keyed hash (HMAC-SHA256) of a code with a key of at least 32 bytes. It normalizes the code first, so the code as typed gives the same hash:

    // 32 random bytes, kept outside the database.
    string hashKeyText = builder.Configuration["SetupCodes:HashKey"] ?? throw new InvalidOperationException("The SetupCodes:HashKey setting is missing.");
    byte[] hashKey = Convert.FromBase64String(hashKeyText);
    
    // Creating a code: store its hash and email the code.
    string code = ShortCode.Generate();
    string codeHash = ShortCode.ComputeHash(code, hashKey);
    
    // Checking a typed code.
    if (ShortCode.Normalize(command.Code) is not { } typed)
        throw new ValidationApiException("This is not a valid code.");
    
    string typedHash = ShortCode.ComputeHash(typed, hashKey);
    var setup = await db.Setups.SingleOrDefaultAsync(s => s.CodeHash == typedHash);
    

    Rate Limits

    The per-user limit on wrong device link codes does not limit an attacker spread across many accounts, and the endpoints that start flows without a session use storage for each flow. AddClientFixedWindowLimiter limits endpoints per IP address, such as the ones that start device links, look up and approve codes, redeem sign-in codes and check the app's own codes. See Rate Limiting.

    Options

    SignInCodeOptions, DeviceLinkOptions and MemoryShortLivedStoreOptions are configured through the registration methods, or bound from configuration like any options class:

    builder.Services.AddDeviceLinks(options => options.Lifetime = TimeSpan.FromMinutes(5));
    builder.Services.Configure<SignInCodeOptions>(builder.Configuration.GetSection("SignInCodes"));
    
    Option Default Purpose
    SignInCodeOptions.Lifetime 10 minutes How long a sign-in code can be redeemed. A device link's code is valid for at least as long as the link can be polled.
    DeviceLinkOptions.Lifetime 10 minutes How long a device link waits for approval, and how long wrong codes count against a user.
    MemoryShortLivedStoreOptions.MaxSize 256 MiB How much the memory store holds, in bytes (see The memory store's size).

    Next Steps

    Continue with these related articles:

    • Session Handling - Creating the session once the code is redeemed.
    • Passkeys - Signing in with a passkey, then with a sign-in code.
    • Running Multiple Servers - Sharing the short-lived store between servers.
    • Building API Clients - The client methods that call your sign-in endpoints.
    © Singulink. All rights reserved.