using System.Buffers.Text; using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace DodoSSH.Client.Auth; /// /// Obtains an identity-provider signature over a set of public keys. /// /// /// Narrower than the whole OIDC client on purpose. Enrollment needs exactly this one capability, and /// depending on the full client would drag discovery, token exchange and refresh into every test of /// it — which is how a test for key binding ends up needing a stubbed token endpoint. /// public interface IKeyBindingAuthorizer { /// /// Runs an authorization whose nonce is a key statement's hash. /// /// From KeyStatementCodec.ComputeNonce. /// Cancels the wait for the browser. /// The ID token to hand to the enrollment endpoint. Task AuthorizeKeyBindingAsync(string bindingNonce, CancellationToken cancellationToken); } /// /// Authorization Code with PKCE on a loopback redirect, for a native public client. /// /// /// /// Hand-rolled rather than delegating to an OIDC library, for one specific reason: the /// identity-provider key binding needs the nonce 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. /// /// /// 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. /// /// public sealed class OidcClient( HttpClient http, IBrowserLauncher browser, TimeProvider clock, OidcClientOptions options) : IKeyBindingAuthorizer { private readonly OidcDiscoveryClient discovery = new(http); /// Runs an interactive sign-in and returns the resulting tokens. public async Task 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(StringComparer.Ordinal) { ["grant_type"] = "authorization_code", ["code"] = code, ["redirect_uri"] = redirectUri.AbsoluteUri, ["client_id"] = options.ClientId, ["code_verifier"] = pkce.CodeVerifier, }, cancellationToken) .ConfigureAwait(false); } /// Exchanges a refresh token for a fresh access token. /// /// 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. /// public async Task RefreshAsync(string refreshToken, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(refreshToken); var metadata = await GetMetadataAsync(cancellationToken).ConfigureAwait(false); var refreshed = await ExchangeAsync( metadata, new Dictionary(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; } /// /// Runs a second authorization whose sole purpose is to have the provider sign over a set of /// public keys. /// /// /// /// This is the primary public-key trust anchor. The 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. /// /// /// prompt=login 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. /// /// /// Only openid is requested. A second refresh token here would be one more long-lived /// credential to store for no benefit. /// /// /// /// From KeyStatementCodec.ComputeNonce. Must be the statement being enrolled. /// /// Cancels the wait for the browser. /// The ID token to hand to POST /api/v1/me/enrollment. public async Task 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(StringComparer.Ordinal) { ["nonce"] = bindingNonce, ["prompt"] = "login", }, cancellationToken) .ConfigureAwait(false); var tokens = await ExchangeAsync( metadata, new Dictionary(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 GetMetadataAsync(CancellationToken cancellationToken) => discovery.GetAsync(options.Authority, options.RequireHttpsMetadata, cancellationToken); /// Opens the browser and waits for a matching callback. private async Task<(string Code, Uri RedirectUri, PkcePair Pkce)> AuthorizeAsync( OidcProviderMetadata metadata, IReadOnlyList scopes, IReadOnlyDictionary? 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); } /// Checks a callback and returns its authorization code. 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; } /// /// Extra parameters are applied last and may override the defaults. That is what lets the key /// binding flow supply its own nonce and prompt, which is the reason this is /// hand-rolled at all. /// private static Uri BuildAuthorizeUri( OidcProviderMetadata metadata, string clientId, IReadOnlyList scopes, PkcePair pkce, string state, Uri redirectUri, IReadOnlyDictionary? extraParameters) { var parameters = new Dictionary(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 ExchangeAsync( OidcProviderMetadata metadata, Dictionary 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; /// /// Reads the nonce claim out of an ID token without verifying its signature. /// /// /// 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. /// /// This reasoning does not 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. /// /// 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."); } } }