Add the OIDC client: PKCE loopback sign-in and the key binding flow

Authorization Code with PKCE on a loopback redirect, per RFC 6749, RFC 7636
and RFC 8252. Zero package references: the flow is fully specified, and the
one thing a library would own for us -- nonce generation and validation -- is
exactly what the key binding needs to control. Duende's OidcClient generates
and validates its own nonce as an internal detail, and the binding requires
the nonce be a specific value: the hash of the key statement being enrolled.
Fighting that is worse than owning the flow.

AuthorizeKeyBindingAsync is the client half of the primary trust anchor. It
runs a second authorization with nonce set to the statement hash and
prompt=login, so the ID token that returns is the provider's signature over
exactly those public keys, attesting to a user present now rather than to a
session opened at some unknown earlier time. It requests only openid -- a
second refresh token would be one more long-lived credential for no benefit
-- and rejects a token whose nonce is not the one it asked for, because
enrolling that would store evidence verifying against keys we are not
publishing.

The nonce is read without validating the ID token's signature. Sanctioned by
OIDC Core 3.1.3.7: for a token received by direct communication with the
token endpoint, TLS server authentication may stand in for signature
checking. That reasoning does not extend to another user's binding, which
arrives via the DodoSSH server and must be verified against JWKS fetched
directly -- the directory work in M3.

Raw TcpListener rather than HttpListener for the redirect: an ephemeral port
can be bound and read atomically instead of picking one and hoping it is
still free, there is no HTTP.SYS URL-ACL question on Windows, and the whole
surface is one request line. It answers 404 on other paths and keeps
waiting, because a browser asks for /favicon.ico first and treating that as
the callback would abort every sign-in. 127.0.0.1 rather than localhost: RFC
8252 permits either, but the name resolves through the hosts file.

20 tests, driving the real listener over TCP with a fake browser that
actually fetches the redirect -- injecting a fabricated callback would skip
the parsing, path filtering and response writing that can break. Mostly
negative, because the loopback port is reachable by every local process: a
response with the wrong state is rejected *and* never reaches the token
endpoint, metadata declaring an issuer other than its own authority is
rejected (RFC 8414 3.3, without which a mix-up attack works), a provider
offering only 'plain' is fatal rather than a silent downgrade, and the
verifier sent is checked against the challenge advertised so PKCE is not
theatre that only fails in production.

Two bugs caught by writing the tests: the authorize URL builder dropped
client_id entirely after a refactor, and CancellationTokenSource.CancelAfter
has no TimeProvider overload -- so the browser timeout is now constructed
with the clock and a test can advance it instead of waiting five minutes.
This commit is contained in:
2026-07-28 21:13:35 +02:00
parent e65d738912
commit 94f66be5e8
16 changed files with 3122 additions and 0 deletions
+407
View File
@@ -0,0 +1,407 @@
using System.Buffers.Text;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace DodoSSH.Client.Auth;
/// <summary>
/// Authorization Code with PKCE on a loopback redirect, for a native public client.
/// </summary>
/// <remarks>
/// <para>
/// Hand-rolled rather than delegating to an OIDC library, for one specific reason: the
/// identity-provider key binding needs the <c>nonce</c> set to an exact value — the hash of a key
/// statement — and libraries generate and validate their own nonce as an internal detail. Fighting
/// that is worse than owning a flow the RFCs specify completely.
/// </para>
/// <para>
/// State and PKCE are per-request and never reused. The response is rejected unless the state
/// matches, which is what stops an attacker feeding the listener a code of their own — the loopback
/// port is reachable by any local process.
/// </para>
/// </remarks>
public sealed class OidcClient(
HttpClient http,
IBrowserLauncher browser,
TimeProvider clock,
OidcClientOptions options)
{
private readonly OidcDiscoveryClient discovery = new(http);
/// <summary>Runs an interactive sign-in and returns the resulting tokens.</summary>
public async Task<TokenSet> SignInAsync(CancellationToken cancellationToken)
{
var metadata = await GetMetadataAsync(cancellationToken).ConfigureAwait(false);
var (code, redirectUri, pkce) = await AuthorizeAsync(
metadata,
options.Scopes,
extraParameters: null,
cancellationToken)
.ConfigureAwait(false);
return await ExchangeAsync(
metadata,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["grant_type"] = "authorization_code",
["code"] = code,
["redirect_uri"] = redirectUri.AbsoluteUri,
["client_id"] = options.ClientId,
["code_verifier"] = pkce.CodeVerifier,
},
cancellationToken)
.ConfigureAwait(false);
}
/// <summary>Exchanges a refresh token for a fresh access token.</summary>
/// <remarks>
/// A provider may rotate the refresh token, so the caller must persist whatever comes back
/// rather than keeping the one it sent. Treating rotation as optional is how a client ends up
/// permanently signed out after one refresh.
/// </remarks>
public async Task<TokenSet> RefreshAsync(string refreshToken, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(refreshToken);
var metadata = await GetMetadataAsync(cancellationToken).ConfigureAwait(false);
var refreshed = await ExchangeAsync(
metadata,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["grant_type"] = "refresh_token",
["refresh_token"] = refreshToken,
["client_id"] = options.ClientId,
},
cancellationToken)
.ConfigureAwait(false);
// Providers that do not rotate omit the field entirely; carry the old one forward so the
// caller can persist one value unconditionally.
return refreshed.RefreshToken is null
? refreshed with { RefreshToken = refreshToken }
: refreshed;
}
/// <summary>
/// Runs a second authorization whose sole purpose is to have the provider sign over a set of
/// public keys.
/// </summary>
/// <remarks>
/// <para>
/// This is the primary public-key trust anchor. The <paramref name="bindingNonce"/> is the hash
/// of the key statement (docs/crypto.md §7.1), so the ID token that comes back is the provider's
/// signature over exactly those keys. The DodoSSH server cannot mint that signature, so it cannot
/// fabricate a key for a user who never enrolled — the attack that would otherwise let an
/// operator read every vault by publishing its own key as yours. See ADR 0001.
/// </para>
/// <para>
/// <c>prompt=login</c> forces a fresh authentication rather than reusing an existing session, so
/// the assertion attests to a user present at this moment rather than to a session opened at some
/// unknown earlier time.
/// </para>
/// <para>
/// Only <c>openid</c> is requested. A second refresh token here would be one more long-lived
/// credential to store for no benefit.
/// </para>
/// </remarks>
/// <param name="bindingNonce">
/// From <c>KeyStatementCodec.ComputeNonce</c>. Must be the statement being enrolled.
/// </param>
/// <param name="cancellationToken">Cancels the wait for the browser.</param>
/// <returns>The ID token to hand to <c>POST /api/v1/me/enrollment</c>.</returns>
public async Task<string> AuthorizeKeyBindingAsync(
string bindingNonce,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bindingNonce);
var metadata = await GetMetadataAsync(cancellationToken).ConfigureAwait(false);
var (code, redirectUri, pkce) = await AuthorizeAsync(
metadata,
["openid"],
new Dictionary<string, string>(StringComparer.Ordinal)
{
["nonce"] = bindingNonce,
["prompt"] = "login",
},
cancellationToken)
.ConfigureAwait(false);
var tokens = await ExchangeAsync(
metadata,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["grant_type"] = "authorization_code",
["code"] = code,
["redirect_uri"] = redirectUri.AbsoluteUri,
["client_id"] = options.ClientId,
["code_verifier"] = pkce.CodeVerifier,
},
cancellationToken)
.ConfigureAwait(false);
if (tokens.IdToken is null)
{
throw new OidcException(
"The provider returned no ID token, so nothing binds the keys. Check that the "
+ "'openid' scope is permitted for this client.");
}
var returned = ReadNonceClaim(tokens.IdToken);
if (!string.Equals(returned, bindingNonce, StringComparison.Ordinal))
{
// A token that does not carry our nonce binds some other statement. Enrolling it would
// store evidence that verifies against keys we are not publishing.
throw new OidcException(
"The ID token's nonce is not the key statement's hash, so it does not bind these keys.");
}
return tokens.IdToken;
}
private Task<OidcProviderMetadata> GetMetadataAsync(CancellationToken cancellationToken) =>
discovery.GetAsync(options.Authority, options.RequireHttpsMetadata, cancellationToken);
/// <summary>Opens the browser and waits for a matching callback.</summary>
private async Task<(string Code, Uri RedirectUri, PkcePair Pkce)> AuthorizeAsync(
OidcProviderMetadata metadata,
IReadOnlyList<string> scopes,
IReadOnlyDictionary<string, string>? extraParameters,
CancellationToken cancellationToken)
{
if (metadata.CodeChallengeMethodsSupported.Count > 0
&& !metadata.CodeChallengeMethodsSupported.Contains(PkcePair.Method, StringComparer.Ordinal))
{
// Continuing without PKCE is not an option for a public client, so this is fatal rather
// than a downgrade.
throw new OidcException(
$"The provider does not advertise the {PkcePair.Method} code challenge method.");
}
var pkce = PkcePair.Create();
var state = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
using var listener = new LoopbackCallbackListener(options.RedirectPath);
var redirectUri = listener.RedirectUri;
var authorizeUri = BuildAuthorizeUri(
metadata, options.ClientId, scopes, pkce, state, redirectUri, extraParameters);
await browser.OpenAsync(authorizeUri, cancellationToken).ConfigureAwait(false);
// Constructed with the TimeProvider rather than CancelAfter, so a test can advance a fake
// clock instead of waiting out a five-minute browser timeout.
using var timeout = new CancellationTokenSource(options.BrowserTimeout, clock);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
timeout.Token);
CallbackResult callback;
try
{
callback = await listener
.WaitForCallbackAsync(options.CompletionHtml, linked.Token)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new OidcException(
$"No authorization response arrived within {options.BrowserTimeout}.");
}
return (ExtractCode(callback, state), redirectUri, pkce);
}
/// <summary>Checks a callback and returns its authorization code.</summary>
private static string ExtractCode(CallbackResult callback, string expectedState)
{
if (callback.Error is { } error)
{
throw new OidcException(
$"The provider refused authorization: {callback.ErrorDescription ?? error}.",
error);
}
// Checked before the code is touched. Constant-time comparison is unnecessary — state is not
// a secret being guessed byte by byte — but it must be exact and ordinal.
if (!string.Equals(callback.State, expectedState, StringComparison.Ordinal))
{
throw new OidcException(
"The authorization response carried the wrong state and was discarded. Any local "
+ "process can reach the loopback port, so an unmatched response is treated as hostile.");
}
if (string.IsNullOrEmpty(callback.Code))
{
throw new OidcException("The authorization response carried no code.");
}
return callback.Code;
}
/// <remarks>
/// Extra parameters are applied last and may override the defaults. That is what lets the key
/// binding flow supply its own <c>nonce</c> and <c>prompt</c>, which is the reason this is
/// hand-rolled at all.
/// </remarks>
private static Uri BuildAuthorizeUri(
OidcProviderMetadata metadata,
string clientId,
IReadOnlyList<string> scopes,
PkcePair pkce,
string state,
Uri redirectUri,
IReadOnlyDictionary<string, string>? extraParameters)
{
var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
{
["response_type"] = "code",
["client_id"] = clientId,
["redirect_uri"] = redirectUri.AbsoluteUri,
["scope"] = string.Join(' ', scopes),
["state"] = state,
["code_challenge"] = pkce.CodeChallenge,
["code_challenge_method"] = PkcePair.Method,
};
if (extraParameters is not null)
{
foreach (var (name, value) in extraParameters)
{
parameters[name] = value;
}
}
// The endpoint may already carry a query — Keycloak's does not, but some providers pin a
// tenant or an audience there, and clobbering it would break them.
var uri = new StringBuilder(metadata.AuthorizationEndpoint.AbsoluteUri);
uri.Append(metadata.AuthorizationEndpoint.Query.Length > 0 ? '&' : '?');
var first = true;
foreach (var (name, value) in parameters)
{
if (!first)
{
uri.Append('&');
}
first = false;
uri.Append(Uri.EscapeDataString(name)).Append('=').Append(Uri.EscapeDataString(value));
}
return new Uri(uri.ToString(), UriKind.Absolute);
}
private async Task<TokenSet> ExchangeAsync(
OidcProviderMetadata metadata,
Dictionary<string, string> form,
CancellationToken cancellationToken)
{
using var content = new FormUrlEncodedContent(form);
using var response = await http
.PostAsync(metadata.TokenEndpoint, content, cancellationToken)
.ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
using var document = ParseOrThrow(body, response.StatusCode);
var root = document.RootElement;
if (!response.IsSuccessStatusCode)
{
var error = ReadString(root, "error") ?? "unknown_error";
throw new OidcException(
$"The token endpoint returned {(int)response.StatusCode}: "
+ $"{ReadString(root, "error_description") ?? error}.",
error);
}
var accessToken = ReadString(root, "access_token")
?? throw new OidcException("The token response contained no access token.");
// Absent expires_in is legal and means unspecified. Treating that as "never expires" would
// hand the caller a token it never refreshes; a short assumed lifetime degrades to an extra
// refresh instead.
var lifetime = root.TryGetProperty("expires_in", out var expires)
&& expires.ValueKind == JsonValueKind.Number
&& expires.TryGetInt64(out var seconds)
? TimeSpan.FromSeconds(seconds)
: TimeSpan.FromMinutes(5);
return new TokenSet(
AccessToken: accessToken,
RefreshToken: ReadString(root, "refresh_token"),
IdToken: ReadString(root, "id_token"),
ExpiresAtUtc: clock.GetUtcNow() + lifetime,
Scope: ReadString(root, "scope"));
}
private static JsonDocument ParseOrThrow(string body, System.Net.HttpStatusCode status)
{
try
{
return JsonDocument.Parse(body);
}
catch (JsonException exception)
{
throw new OidcException(
$"The token endpoint returned {(int)status} with a body that is not JSON: "
+ exception.Message);
}
}
private static string? ReadString(JsonElement root, string name) =>
root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
/// <summary>
/// Reads the <c>nonce</c> claim out of an ID token without verifying its signature.
/// </summary>
/// <remarks>
/// Sanctioned by OpenID Connect Core §3.1.3.7: when an ID token is received by direct
/// communication with the token endpoint, TLS server authentication may stand in for checking the
/// token signature. It is the channel that establishes who sent this, so the nonce check here is
/// about detecting a provider bug or a mixed-up response, not about trusting an unsigned token.
/// <para>
/// This reasoning does <b>not</b> extend to another user's key binding, which arrives through the
/// DodoSSH server and must be verified against the provider's JWKS fetched directly. That is the
/// directory work in M3.
/// </para>
/// </remarks>
private static string? ReadNonceClaim(string idToken)
{
var segments = idToken.Split('.');
if (segments.Length < 2)
{
throw new OidcException("The ID token is not a JWT.");
}
byte[] payload;
try
{
payload = Base64Url.DecodeFromChars(segments[1]);
}
catch (FormatException)
{
throw new OidcException("The ID token's payload is not valid base64url.");
}
try
{
using var document = JsonDocument.Parse(payload);
return ReadString(document.RootElement, "nonce");
}
catch (JsonException)
{
throw new OidcException("The ID token's payload is not JSON.");
}
}
}