Public Access
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:
@@ -16,11 +16,13 @@
|
|||||||
<Project Path="src/DodoSSH.Domain/DodoSSH.Domain.csproj" />
|
<Project Path="src/DodoSSH.Domain/DodoSSH.Domain.csproj" />
|
||||||
<Project Path="src/DodoSSH.Infrastructure/DodoSSH.Infrastructure.csproj" />
|
<Project Path="src/DodoSSH.Infrastructure/DodoSSH.Infrastructure.csproj" />
|
||||||
<Project Path="src/DodoSSH.Api/DodoSSH.Api.csproj" />
|
<Project Path="src/DodoSSH.Api/DodoSSH.Api.csproj" />
|
||||||
|
<Project Path="src/DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" />
|
||||||
<Project Path="src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
<Project Path="src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/DodoSSH.Api.Tests/DodoSSH.Api.Tests.csproj" />
|
<Project Path="tests/DodoSSH.Api.Tests/DodoSSH.Api.Tests.csproj" />
|
||||||
|
<Project Path="tests/DodoSSH.Client.Auth.Tests/DodoSSH.Client.Auth.Tests.csproj" />
|
||||||
<Project Path="tests/DodoSSH.Client.Ssh.Tests/DodoSSH.Client.Ssh.Tests.csproj" />
|
<Project Path="tests/DodoSSH.Client.Ssh.Tests/DodoSSH.Client.Ssh.Tests.csproj" />
|
||||||
<Project Path="tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj" />
|
<Project Path="tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj" />
|
||||||
<Project Path="tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj" />
|
<Project Path="tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj" />
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package man
|
|||||||
**Linux ships AppImage and Flatpak first**, specifically so the WebKit runtime is bundled rather
|
**Linux ships AppImage and Flatpak first**, specifically so the WebKit runtime is bundled rather
|
||||||
than assumed present on the user's machine.
|
than assumed present on the user's machine.
|
||||||
|
|
||||||
|
**Opening the system browser depends on the platform handler.** `SystemBrowserLauncher` uses
|
||||||
|
`UseShellExecute`, which delegates to `ShellExecute` on Windows, `open` on macOS and `xdg-open` on
|
||||||
|
Linux. *Unverified off Windows:* `xdg-open` comes from `xdg-utils`, which is not guaranteed on a
|
||||||
|
minimal desktop or inside a Flatpak sandbox — where the portal is the correct route instead. If
|
||||||
|
sign-in silently does nothing on Linux, this is the first thing to check. `IBrowserLauncher` exists
|
||||||
|
so a platform-specific opener can be substituted without touching the flow.
|
||||||
|
|
||||||
## Build and CI
|
## Build and CI
|
||||||
|
|
||||||
**Integration tests need a Docker daemon** (Testcontainers). They run on `ubuntu-latest` in CI.
|
**Integration tests need a Docker daemon** (Testcontainers). They run on `ubuntu-latest` in CI.
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Auth;
|
||||||
|
|
||||||
|
/// <summary>Opens a URL in the user's browser.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An interface so the sign-in flow can be driven end to end in tests without a browser, and so a
|
||||||
|
/// packaged build can substitute a platform-specific opener if the default proves unreliable.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IBrowserLauncher
|
||||||
|
{
|
||||||
|
/// <summary>Opens the authorization URL.</summary>
|
||||||
|
Task OpenAsync(Uri url, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the system browser.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The <em>system</em> browser, never an embedded one. RFC 8252 §8.12: an embedded user-agent lets
|
||||||
|
/// the application read the user's credentials as they are typed, and denies the user any way to see
|
||||||
|
/// the real address bar or inspect the provider's certificate. It also cannot reuse an existing
|
||||||
|
/// single-sign-on session.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>UseShellExecute</c> delegates to the platform handler — <c>ShellExecute</c> on Windows,
|
||||||
|
/// <c>open</c> on macOS, <c>xdg-open</c> on Linux. The Linux path depends on <c>xdg-utils</c> being
|
||||||
|
/// installed, which is not guaranteed on a minimal desktop; see docs/platform-flags.md.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SystemBrowserLauncher : IBrowserLauncher
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task OpenAsync(Uri url, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(url);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
// Only ever an authorization URL, but asserted rather than assumed: UseShellExecute on a
|
||||||
|
// file path would launch it, so a scheme check is the difference between opening a page and
|
||||||
|
// executing something.
|
||||||
|
if (!string.Equals(url.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)
|
||||||
|
&& !string.Equals(url.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Refusing to open a '{url.Scheme}' URL.", nameof(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
using var process = Process.Start(new ProcessStartInfo(url.AbsoluteUri)
|
||||||
|
{
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
OIDC for a native public client: Authorization Code with PKCE on a loopback redirect.
|
||||||
|
|
||||||
|
No package references at all, deliberately. The flow is fully specified by RFC 6749,
|
||||||
|
RFC 7636 and RFC 8252, and the one thing a library would own for us — nonce generation
|
||||||
|
and validation — is precisely what the identity-provider key binding needs to control
|
||||||
|
exactly. See KeyBindingAuthorization.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="DodoSSH.Client.Auth.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Auth;
|
||||||
|
|
||||||
|
/// <summary>The query parameters an authorization callback delivered.</summary>
|
||||||
|
/// <param name="Parameters">Every query parameter, decoded.</param>
|
||||||
|
public sealed record CallbackResult(IReadOnlyDictionary<string, string> Parameters)
|
||||||
|
{
|
||||||
|
/// <summary>The authorization code, when the callback carried one.</summary>
|
||||||
|
public string? Code => Parameters.GetValueOrDefault("code");
|
||||||
|
|
||||||
|
/// <summary>The state value echoed back, for the caller to compare.</summary>
|
||||||
|
public string? State => Parameters.GetValueOrDefault("state");
|
||||||
|
|
||||||
|
/// <summary>The OAuth error code, when the provider refused.</summary>
|
||||||
|
public string? Error => Parameters.GetValueOrDefault("error");
|
||||||
|
|
||||||
|
/// <summary>The provider's human-readable error description.</summary>
|
||||||
|
public string? ErrorDescription => Parameters.GetValueOrDefault("error_description");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single-use HTTP listener on an ephemeral loopback port, for the authorization redirect.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// RFC 8252 §7.3: a native client receives the redirect on a loopback interface, using the system
|
||||||
|
/// browser rather than an embedded one so the user can see the real address bar and the provider's
|
||||||
|
/// own certificate. A custom URI scheme is the alternative and is worse — any application on the
|
||||||
|
/// machine can claim a scheme, whereas only one process can hold a port.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Raw <see cref="TcpListener"/> rather than <c>HttpListener</c>, for three reasons: an ephemeral
|
||||||
|
/// port can be bound and read atomically instead of picking a port and hoping it is still free a
|
||||||
|
/// moment later; there is no HTTP.SYS URL-ACL question on Windows; and the entire surface is one
|
||||||
|
/// request line, which is easier to reason about than a general-purpose HTTP server.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The literal <c>127.0.0.1</c> is used rather than <c>localhost</c>. RFC 8252 §8.3 permits either,
|
||||||
|
/// but the name resolves through the hosts file and DNS, and the address does not.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class LoopbackCallbackListener : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>Longest request line and header block accepted, as a denial-of-service bound.</summary>
|
||||||
|
private const int MaximumRequestBytes = 16 * 1024;
|
||||||
|
|
||||||
|
private readonly TcpListener listener;
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
/// <summary>Starts listening on a free loopback port.</summary>
|
||||||
|
/// <param name="path">The redirect path, including its leading slash.</param>
|
||||||
|
public LoopbackCallbackListener(string path = "/callback")
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||||
|
|
||||||
|
if (!path.StartsWith('/'))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The redirect path must begin with '/'.", nameof(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
Path = path;
|
||||||
|
|
||||||
|
// Port 0 lets the OS pick, and the bound port is readable immediately — so there is no
|
||||||
|
// window in which another process could take the port between choosing and binding it.
|
||||||
|
listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
Port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The redirect path this listener answers on.</summary>
|
||||||
|
public string Path { get; }
|
||||||
|
|
||||||
|
/// <summary>The port the OS assigned.</summary>
|
||||||
|
public int Port { get; }
|
||||||
|
|
||||||
|
/// <summary>The exact redirect URI to send in the authorization request.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The token endpoint compares this byte-for-byte against what it saw at authorization time, so
|
||||||
|
/// it must be built once and reused rather than reconstructed.
|
||||||
|
/// </remarks>
|
||||||
|
public Uri RedirectUri => new(
|
||||||
|
string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{Port}{Path}"),
|
||||||
|
UriKind.Absolute);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Waits for the browser to arrive at the redirect.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Requests to any other path are answered 404 and ignored rather than ending the wait. A
|
||||||
|
/// browser will happily ask for <c>/favicon.ico</c> first, and treating that as the callback
|
||||||
|
/// would abort every sign-in.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="completionHtml">The page shown in the browser once the code is captured.</param>
|
||||||
|
/// <param name="cancellationToken">Cancels the wait — a user closing the sign-in dialog.</param>
|
||||||
|
public async Task<CallbackResult> WaitForCallbackAsync(
|
||||||
|
string completionHtml,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
using var client = await listener
|
||||||
|
.AcceptTcpClientAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var stream = client.GetStream();
|
||||||
|
await using var streamScope = stream.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var target = await ReadRequestTargetAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (target is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolved against a fixed base purely to reuse the query parser; the authority is
|
||||||
|
// never used.
|
||||||
|
var requested = new Uri(new Uri("http://127.0.0.1/", UriKind.Absolute), target);
|
||||||
|
|
||||||
|
if (!string.Equals(requested.AbsolutePath, Path, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
await RespondAsync(stream, "404 Not Found", "<h1>Not found</h1>", cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await RespondAsync(stream, "200 OK", completionHtml, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new CallbackResult(ParseQuery(requested.Query));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
disposed = true;
|
||||||
|
listener.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the request target from the request line, then drains the headers.</summary>
|
||||||
|
/// <returns>The target, or <see langword="null"/> if the request was not a usable GET.</returns>
|
||||||
|
private static async Task<string?> ReadRequestTargetAsync(
|
||||||
|
Stream stream,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var buffer = new byte[MaximumRequestBytes];
|
||||||
|
var count = 0;
|
||||||
|
|
||||||
|
// Read until the end of the header block. The body is irrelevant: a redirect is a GET.
|
||||||
|
while (count < buffer.Length)
|
||||||
|
{
|
||||||
|
var read = await stream
|
||||||
|
.ReadAsync(buffer.AsMemory(count), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (read == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
count += read;
|
||||||
|
|
||||||
|
if (Encoding.ASCII.GetString(buffer, 0, count).Contains("\r\n\r\n", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = Encoding.ASCII.GetString(buffer, 0, count);
|
||||||
|
var firstLine = text.Split("\r\n", StringSplitOptions.None)[0];
|
||||||
|
var parts = firstLine.Split(' ');
|
||||||
|
|
||||||
|
return parts.Length >= 2 && string.Equals(parts[0], "GET", StringComparison.Ordinal)
|
||||||
|
? parts[1]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RespondAsync(
|
||||||
|
Stream stream,
|
||||||
|
string status,
|
||||||
|
string html,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var body = Encoding.UTF8.GetBytes(html);
|
||||||
|
|
||||||
|
// Plain concatenation with an explicit invariant conversion. string.Create's interpolated
|
||||||
|
// overload takes a handler by ref, so a concatenation of interpolated pieces does not bind
|
||||||
|
// to it — and the header must stay byte-exact regardless of culture.
|
||||||
|
var header =
|
||||||
|
"HTTP/1.1 " + status + "\r\n"
|
||||||
|
+ "Content-Type: text/html; charset=utf-8\r\n"
|
||||||
|
+ "Content-Length: " + body.Length.ToString(CultureInfo.InvariantCulture) + "\r\n"
|
||||||
|
|
||||||
|
// The page has served its purpose the moment it renders; caching it would leave the
|
||||||
|
// authorization code's query string in the browser's cache.
|
||||||
|
+ "Cache-Control: no-store\r\n"
|
||||||
|
+ "Connection: close\r\n\r\n";
|
||||||
|
|
||||||
|
await stream.WriteAsync(Encoding.ASCII.GetBytes(header), cancellationToken).ConfigureAwait(false);
|
||||||
|
await stream.WriteAsync(body, cancellationToken).ConfigureAwait(false);
|
||||||
|
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ParseQuery(string query)
|
||||||
|
{
|
||||||
|
var parameters = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var pair in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var separator = pair.IndexOf('=', StringComparison.Ordinal);
|
||||||
|
|
||||||
|
if (separator < 0)
|
||||||
|
{
|
||||||
|
parameters[Uri.UnescapeDataString(pair)] = string.Empty;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = Uri.UnescapeDataString(pair[..separator]);
|
||||||
|
var value = Uri.UnescapeDataString(pair[(separator + 1)..].Replace('+', ' '));
|
||||||
|
|
||||||
|
parameters[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
namespace DodoSSH.Client.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Everything needed to talk to one identity provider.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Populated from the DodoSSH server's <c>/.well-known/dodossh-configuration</c>, which is the whole
|
||||||
|
/// onboarding story: the user types one server URL and the client discovers the authority, the client
|
||||||
|
/// id and the scopes from it.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class OidcClientOptions
|
||||||
|
{
|
||||||
|
/// <summary>The provider's issuer URL.</summary>
|
||||||
|
public required Uri Authority { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The public client identifier registered for the desktop app.</summary>
|
||||||
|
public required string ClientId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scopes requested at sign-in.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>offline_access</c> is what yields a refresh token, and without one the user re-authenticates
|
||||||
|
/// through the browser every time the access token expires.
|
||||||
|
/// </remarks>
|
||||||
|
public IReadOnlyList<string> Scopes { get; init; } = ["openid", "profile", "email", "offline_access"];
|
||||||
|
|
||||||
|
/// <summary>Path the loopback listener answers the redirect on.</summary>
|
||||||
|
public string RedirectPath { get; init; } = "/callback";
|
||||||
|
|
||||||
|
/// <summary>Whether provider metadata must be served over HTTPS. Only false for local development.</summary>
|
||||||
|
public bool RequireHttpsMetadata { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>How long to wait for the user to finish in the browser.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Generous, because the user may have to find a password manager, complete a second factor, or
|
||||||
|
/// approve a push notification on another device.
|
||||||
|
/// </remarks>
|
||||||
|
public TimeSpan BrowserTimeout { get; init; } = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
/// <summary>Page shown in the browser once the callback is captured.</summary>
|
||||||
|
public string CompletionHtml { get; init; } =
|
||||||
|
"""
|
||||||
|
<!doctype html><html><head><meta charset="utf-8"><title>DodoSSH</title></head>
|
||||||
|
<body style="font-family:system-ui;margin:4rem;text-align:center">
|
||||||
|
<h1>Signed in</h1><p>You can close this tab and return to DodoSSH.</p>
|
||||||
|
</body></html>
|
||||||
|
""";
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Auth;
|
||||||
|
|
||||||
|
/// <summary>Raised when an OIDC exchange fails in a way the caller must handle.</summary>
|
||||||
|
public sealed class OidcException(string message, string? errorCode = null) : Exception(message)
|
||||||
|
{
|
||||||
|
/// <summary>The provider's OAuth error code, when it supplied one.</summary>
|
||||||
|
public string? ErrorCode { get; } = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The parts of an OIDC discovery document this client uses.</summary>
|
||||||
|
/// <param name="Issuer">The provider's own idea of its issuer identifier.</param>
|
||||||
|
/// <param name="AuthorizationEndpoint">Where the browser is sent.</param>
|
||||||
|
/// <param name="TokenEndpoint">Where codes and refresh tokens are redeemed.</param>
|
||||||
|
/// <param name="JwksUri">
|
||||||
|
/// Where signing keys are published. Not used to validate tokens received directly from the token
|
||||||
|
/// endpoint, but required in M3 to verify other users' key bindings — fetched from here, never
|
||||||
|
/// proxied through the DodoSSH server, which is the entire point of the binding. See ADR 0001.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="CodeChallengeMethodsSupported">Advertised PKCE methods.</param>
|
||||||
|
public sealed record OidcProviderMetadata(
|
||||||
|
Uri Issuer,
|
||||||
|
Uri AuthorizationEndpoint,
|
||||||
|
Uri TokenEndpoint,
|
||||||
|
Uri? JwksUri,
|
||||||
|
IReadOnlyList<string> CodeChallengeMethodsSupported);
|
||||||
|
|
||||||
|
/// <summary>Fetches and validates an OIDC discovery document.</summary>
|
||||||
|
public sealed class OidcDiscoveryClient(HttpClient http)
|
||||||
|
{
|
||||||
|
private const string DiscoveryPath = ".well-known/openid-configuration";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the discovery document for an authority.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The document's <c>issuer</c> is checked against the authority the URL was built from, per
|
||||||
|
/// RFC 8414 §3.3. Skipping that check is what enables a mix-up attack: an attacker-controlled
|
||||||
|
/// authority can serve metadata pointing at a legitimate provider's endpoints, and the client
|
||||||
|
/// then hands its code to the attacker's token endpoint while believing it is talking to the
|
||||||
|
/// real one.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task<OidcProviderMetadata> GetAsync(
|
||||||
|
Uri authority,
|
||||||
|
bool requireHttps,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(authority);
|
||||||
|
|
||||||
|
if (requireHttps && !string.Equals(authority.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new OidcException(
|
||||||
|
$"The authority '{authority}' is not HTTPS. Plaintext metadata is only ever "
|
||||||
|
+ "acceptable for local development.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A trailing slash matters: without it Uri would replace the last path segment, so an
|
||||||
|
// authority with a realm path would resolve to the wrong document.
|
||||||
|
var basePath = authority.AbsoluteUri.EndsWith('/') ? authority : new Uri(authority.AbsoluteUri + "/");
|
||||||
|
var discoveryUri = new Uri(basePath, DiscoveryPath);
|
||||||
|
|
||||||
|
using var response = await http.GetAsync(discoveryUri, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new OidcException(
|
||||||
|
$"Discovery at '{discoveryUri}' returned {(int)response.StatusCode}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = await response.Content
|
||||||
|
.ReadAsStreamAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await using var contentScope = content.ConfigureAwait(false);
|
||||||
|
|
||||||
|
using var document = await JsonDocument
|
||||||
|
.ParseAsync(content, cancellationToken: cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var metadata = Read(document.RootElement, discoveryUri);
|
||||||
|
|
||||||
|
if (!IssuerMatches(metadata.Issuer, authority))
|
||||||
|
{
|
||||||
|
throw new OidcException(
|
||||||
|
$"The discovery document at '{discoveryUri}' declares issuer '{metadata.Issuer}', "
|
||||||
|
+ $"which is not the authority '{authority}' it was fetched from.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OidcProviderMetadata Read(JsonElement root, Uri discoveryUri) =>
|
||||||
|
new(
|
||||||
|
Issuer: RequireUri(root, "issuer", discoveryUri),
|
||||||
|
AuthorizationEndpoint: RequireUri(root, "authorization_endpoint", discoveryUri),
|
||||||
|
TokenEndpoint: RequireUri(root, "token_endpoint", discoveryUri),
|
||||||
|
JwksUri: OptionalUri(root, "jwks_uri"),
|
||||||
|
CodeChallengeMethodsSupported: ReadStrings(root, "code_challenge_methods_supported"));
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Compared after normalising a single trailing slash, because providers are inconsistent about
|
||||||
|
/// it and a mismatch there is cosmetic rather than an attack.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool IssuerMatches(Uri issuer, Uri authority) =>
|
||||||
|
string.Equals(
|
||||||
|
issuer.AbsoluteUri.TrimEnd('/'),
|
||||||
|
authority.AbsoluteUri.TrimEnd('/'),
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static Uri RequireUri(JsonElement root, string name, Uri discoveryUri)
|
||||||
|
{
|
||||||
|
var value = OptionalUri(root, name)
|
||||||
|
?? throw new OidcException(
|
||||||
|
$"The discovery document at '{discoveryUri}' has no usable '{name}'.");
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Uri? OptionalUri(JsonElement root, string name) =>
|
||||||
|
root.TryGetProperty(name, out var property)
|
||||||
|
&& property.ValueKind == JsonValueKind.String
|
||||||
|
&& Uri.TryCreate(property.GetString(), UriKind.Absolute, out var uri)
|
||||||
|
? uri
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static List<string> ReadStrings(JsonElement root, string name)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty(name, out var property) || property.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var values = new List<string>();
|
||||||
|
foreach (var element in property.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.String && element.GetString() is { } value)
|
||||||
|
{
|
||||||
|
values.Add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System.Buffers.Text;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A PKCE code verifier and its S256 challenge. See RFC 7636.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A public client cannot keep a secret, so PKCE is what stops an authorization code being
|
||||||
|
/// redeemed by anyone who intercepts it — on a loopback redirect that means any other local
|
||||||
|
/// process that manages to receive the callback. The token endpoint only accepts the code
|
||||||
|
/// alongside the verifier whose hash it saw at authorization time.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>S256</c> only. The <c>plain</c> method is still in the RFC and offers no protection
|
||||||
|
/// whatsoever against an attacker who saw the authorization request.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class PkcePair
|
||||||
|
{
|
||||||
|
/// <summary>The challenge method sent to the authorization endpoint.</summary>
|
||||||
|
public const string Method = "S256";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entropy behind the verifier. 32 bytes renders as 43 base64url characters, the RFC's
|
||||||
|
/// minimum length and comfortably beyond guessing.
|
||||||
|
/// </summary>
|
||||||
|
private const int VerifierEntropyBytes = 32;
|
||||||
|
|
||||||
|
private PkcePair(string codeVerifier, string codeChallenge)
|
||||||
|
{
|
||||||
|
CodeVerifier = codeVerifier;
|
||||||
|
CodeChallenge = codeChallenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The secret held until the token exchange. Never leaves the process.</summary>
|
||||||
|
public string CodeVerifier { get; }
|
||||||
|
|
||||||
|
/// <summary>The hash sent with the authorization request.</summary>
|
||||||
|
public string CodeChallenge { get; }
|
||||||
|
|
||||||
|
/// <summary>Generates a fresh pair.</summary>
|
||||||
|
public static PkcePair Create()
|
||||||
|
{
|
||||||
|
// Base64url of random bytes, which satisfies the RFC's unreserved-character set without
|
||||||
|
// any escaping. Generating characters directly from an alphabet would be one more place to
|
||||||
|
// get a modulo bias wrong for no benefit.
|
||||||
|
var verifier = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(VerifierEntropyBytes));
|
||||||
|
|
||||||
|
var challenge = Base64Url.EncodeToString(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));
|
||||||
|
|
||||||
|
return new PkcePair(verifier, challenge);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace DodoSSH.Client.Auth;
|
||||||
|
|
||||||
|
/// <summary>What a token exchange returned.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The refresh token is the one long-lived credential the client holds, and the only part of this
|
||||||
|
/// that belongs in the OS keystore. The access token is short-lived and re-obtainable, and the ID
|
||||||
|
/// token is an assertion rather than a credential. None of them can open the vault: that needs the
|
||||||
|
/// passphrase, which is never stored anywhere.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="AccessToken">Bearer token for the DodoSSH API.</param>
|
||||||
|
/// <param name="RefreshToken">Refresh token, when <c>offline_access</c> was granted.</param>
|
||||||
|
/// <param name="IdToken">Identity assertion, when <c>openid</c> was requested.</param>
|
||||||
|
/// <param name="ExpiresAtUtc">When the access token stops being accepted.</param>
|
||||||
|
/// <param name="Scope">Scopes actually granted, which may be narrower than those requested.</param>
|
||||||
|
public sealed record TokenSet(
|
||||||
|
string AccessToken,
|
||||||
|
string? RefreshToken,
|
||||||
|
string? IdToken,
|
||||||
|
DateTimeOffset ExpiresAtUtc,
|
||||||
|
string? Scope)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the access token should be refreshed before use.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The margin exists because expiry is checked here and enforced by the server after a network
|
||||||
|
/// round trip. Without it a token that is valid at the moment of the check is rejected by the
|
||||||
|
/// time it arrives, which surfaces as a random 401 mid-sync.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="clock">Time source.</param>
|
||||||
|
/// <param name="margin">How far ahead to consider the token already expired.</param>
|
||||||
|
public bool NeedsRefresh(TimeProvider clock, TimeSpan? margin = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(clock);
|
||||||
|
|
||||||
|
return clock.GetUtcNow() + (margin ?? TimeSpan.FromSeconds(60)) >= ExpiresAtUtc;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"dependencies": {
|
||||||
|
"net10.0": {
|
||||||
|
"Meziantou.Analyzer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[3.0.134, )",
|
||||||
|
"resolved": "3.0.134",
|
||||||
|
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
|
||||||
|
},
|
||||||
|
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[5.6.0, )",
|
||||||
|
"resolved": "5.6.0",
|
||||||
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Drives the whole code flow for real: a stubbed provider over HTTP, a fake browser that
|
||||||
|
actually fetches the loopback redirect, and the client's own listener receiving it. The
|
||||||
|
security properties here - state matching, PKCE, issuer validation, nonce binding - are
|
||||||
|
only demonstrated end to end.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../src/DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" />
|
||||||
|
<ProjectReference Include="../../src/DodoSSH.Crypto/DodoSSH.Crypto.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="WireMock.Net" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.Web;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Auth.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stands in for the system browser, and actually fetches the loopback redirect.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Genuinely drives the client's own <see cref="LoopbackCallbackListener"/> over TCP rather than
|
||||||
|
/// injecting a callback result. The listener's request parsing, path filtering and response writing
|
||||||
|
/// are all part of what could break, and none of it is exercised by handing the flow a fabricated
|
||||||
|
/// code.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The redirect is fetched on a background task, not awaited inside <see cref="OpenAsync"/>. The
|
||||||
|
/// client awaits <c>OpenAsync</c> before it begins accepting, so fetching inline would deadlock: the
|
||||||
|
/// browser would be waiting for a response that only arrives once the client starts listening.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class FakeBrowser : IBrowserLauncher
|
||||||
|
{
|
||||||
|
private readonly Func<NameValueCollection, IReadOnlyDictionary<string, string>>? buildCallback;
|
||||||
|
|
||||||
|
/// <param name="buildCallback">
|
||||||
|
/// Produces the callback query parameters from the authorization request's parameters. Defaults
|
||||||
|
/// to a successful code response echoing the state back.
|
||||||
|
/// </param>
|
||||||
|
internal FakeBrowser(
|
||||||
|
Func<NameValueCollection, IReadOnlyDictionary<string, string>>? buildCallback = null) =>
|
||||||
|
this.buildCallback = buildCallback;
|
||||||
|
|
||||||
|
/// <summary>The authorization URL the client asked to open.</summary>
|
||||||
|
internal Uri? OpenedUrl { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>The authorization request's query parameters.</summary>
|
||||||
|
internal NameValueCollection AuthorizeParameters =>
|
||||||
|
HttpUtility.ParseQueryString(OpenedUrl?.Query ?? string.Empty);
|
||||||
|
|
||||||
|
/// <summary>The background fetch, so a test can surface its failures.</summary>
|
||||||
|
internal Task? CallbackDelivery { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task OpenAsync(Uri url, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
OpenedUrl = url;
|
||||||
|
|
||||||
|
var parameters = HttpUtility.ParseQueryString(url.Query);
|
||||||
|
var redirectUri = parameters["redirect_uri"]
|
||||||
|
?? throw new InvalidOperationException("The authorization URL carried no redirect_uri.");
|
||||||
|
|
||||||
|
var callback = buildCallback is null
|
||||||
|
? new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["code"] = "authorization-code",
|
||||||
|
["state"] = parameters["state"] ?? string.Empty,
|
||||||
|
}
|
||||||
|
: buildCallback(parameters);
|
||||||
|
|
||||||
|
CallbackDelivery = Task.Run(() => FetchAsync(redirectUri, callback), cancellationToken);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task FetchAsync(
|
||||||
|
string redirectUri,
|
||||||
|
IReadOnlyDictionary<string, string> callback)
|
||||||
|
{
|
||||||
|
var query = string.Join(
|
||||||
|
'&',
|
||||||
|
callback.Select(p =>
|
||||||
|
$"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}"));
|
||||||
|
|
||||||
|
using var client = new HttpClient();
|
||||||
|
|
||||||
|
// A real browser asks for this first. The listener must ignore it rather than treating it as
|
||||||
|
// the callback, so it is part of the flow under test.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var favicon = await client.GetAsync(new Uri($"{redirectUri}/../favicon.ico"));
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
// The listener closes the connection after answering; a transport-level failure here is
|
||||||
|
// not what the test is about.
|
||||||
|
}
|
||||||
|
|
||||||
|
using var response = await client.GetAsync(new Uri($"{redirectUri}?{query}"));
|
||||||
|
_ = await response.Content.ReadAsStringAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
using System.Collections.Specialized;
|
||||||
|
using DodoSSH.Crypto;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Auth.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The authorization code flow end to end, and the rejections that make it safe.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A loopback redirect is reachable by every process on the machine, so most of the value here is in
|
||||||
|
/// the negative cases: a response with the wrong state, a provider whose metadata disagrees with its
|
||||||
|
/// own authority, an ID token that binds a different key statement.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class OidcClientTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly StubProvider provider = new();
|
||||||
|
private readonly HttpClient http = new();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
http.Dispose();
|
||||||
|
provider.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- The happy path ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SignIn_CompletesTheFlowAndReturnsTokens()
|
||||||
|
{
|
||||||
|
provider.StubTokenResponse();
|
||||||
|
var browser = new FakeBrowser();
|
||||||
|
|
||||||
|
var tokens = await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
tokens.AccessToken.ShouldBe("access-token");
|
||||||
|
tokens.RefreshToken.ShouldBe("refresh-token");
|
||||||
|
tokens.ExpiresAtUtc.ShouldBeGreaterThan(TimeProvider.System.GetUtcNow());
|
||||||
|
|
||||||
|
await browser.CallbackDelivery!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheAuthorizationRequest_CarriesEverythingTheFlowNeeds()
|
||||||
|
{
|
||||||
|
// The client_id is asserted explicitly: an authorization request without it is rejected by
|
||||||
|
// the provider with an opaque error, and it is easy to drop while refactoring the builder.
|
||||||
|
provider.StubTokenResponse();
|
||||||
|
var browser = new FakeBrowser();
|
||||||
|
|
||||||
|
await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var parameters = browser.AuthorizeParameters;
|
||||||
|
|
||||||
|
parameters["response_type"].ShouldBe("code");
|
||||||
|
parameters["client_id"].ShouldBe("dodossh-desktop");
|
||||||
|
parameters["code_challenge_method"].ShouldBe("S256");
|
||||||
|
parameters["code_challenge"].ShouldNotBeNullOrWhiteSpace();
|
||||||
|
parameters["state"].ShouldNotBeNullOrWhiteSpace();
|
||||||
|
parameters["scope"].ShouldBe("openid profile email offline_access");
|
||||||
|
parameters["redirect_uri"].ShouldNotBeNull();
|
||||||
|
parameters["redirect_uri"]!.ShouldStartWith("http://127.0.0.1:");
|
||||||
|
|
||||||
|
await browser.CallbackDelivery!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheTokenExchange_SendsTheVerifierAndTheSameRedirectUri()
|
||||||
|
{
|
||||||
|
// The token endpoint compares redirect_uri byte-for-byte against what it saw at
|
||||||
|
// authorization time, so a reconstructed value fails in a way that is hard to diagnose.
|
||||||
|
provider.StubTokenResponse();
|
||||||
|
var browser = new FakeBrowser();
|
||||||
|
|
||||||
|
await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var form = provider.LastTokenRequestForm();
|
||||||
|
|
||||||
|
form["grant_type"].ShouldBe("authorization_code");
|
||||||
|
form["code"].ShouldBe("authorization-code");
|
||||||
|
form["client_id"].ShouldBe("dodossh-desktop");
|
||||||
|
form["code_verifier"].ShouldNotBeNullOrWhiteSpace();
|
||||||
|
form["redirect_uri"].ShouldBe(browser.AuthorizeParameters["redirect_uri"]);
|
||||||
|
|
||||||
|
await browser.CallbackDelivery!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheVerifierSent_MatchesTheChallengeAdvertised()
|
||||||
|
{
|
||||||
|
// Otherwise PKCE is theatre: the provider would reject it, but only in production.
|
||||||
|
provider.StubTokenResponse();
|
||||||
|
var browser = new FakeBrowser();
|
||||||
|
|
||||||
|
await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var verifier = provider.LastTokenRequestForm()["code_verifier"];
|
||||||
|
var challenge = browser.AuthorizeParameters["code_challenge"];
|
||||||
|
|
||||||
|
Recompute(verifier).ShouldBe(challenge);
|
||||||
|
|
||||||
|
await browser.CallbackDelivery!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Rejections ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AResponseWithTheWrongState_IsRejected()
|
||||||
|
{
|
||||||
|
// The CSRF defence, and on loopback the defence against any local process racing to deliver
|
||||||
|
// its own code to our listener.
|
||||||
|
provider.StubTokenResponse();
|
||||||
|
|
||||||
|
var browser = new FakeBrowser(_ => new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["code"] = "attacker-code",
|
||||||
|
["state"] = "not-the-state-we-sent",
|
||||||
|
});
|
||||||
|
|
||||||
|
var exception = await Should.ThrowAsync<OidcException>(async () =>
|
||||||
|
await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
exception.Message.ShouldContain("state");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AResponseWithTheWrongState_NeverReachesTheTokenEndpoint()
|
||||||
|
{
|
||||||
|
// A rejection that still redeemed the code would defeat the point.
|
||||||
|
provider.StubTokenResponse();
|
||||||
|
|
||||||
|
var browser = new FakeBrowser(_ => new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["code"] = "attacker-code",
|
||||||
|
["state"] = "wrong",
|
||||||
|
});
|
||||||
|
|
||||||
|
await Should.ThrowAsync<OidcException>(async () =>
|
||||||
|
await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
Should.Throw<InvalidOperationException>(() => provider.LastTokenRequestForm());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AProviderRefusal_SurfacesWithItsErrorCode()
|
||||||
|
{
|
||||||
|
var browser = new FakeBrowser(_ => new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["error"] = "access_denied",
|
||||||
|
["error_description"] = "The user said no",
|
||||||
|
});
|
||||||
|
|
||||||
|
var exception = await Should.ThrowAsync<OidcException>(async () =>
|
||||||
|
await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
exception.ErrorCode.ShouldBe("access_denied");
|
||||||
|
exception.Message.ShouldContain("The user said no");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ACallbackWithNoCode_IsRejected()
|
||||||
|
{
|
||||||
|
var browser = new FakeBrowser(parameters =>
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["state"] = parameters["state"] ?? string.Empty,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Should.ThrowAsync<OidcException>(async () =>
|
||||||
|
await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ATokenEndpointError_Surfaces()
|
||||||
|
{
|
||||||
|
provider.StubTokenError(400, "invalid_grant", "Code already redeemed");
|
||||||
|
var browser = new FakeBrowser();
|
||||||
|
|
||||||
|
var exception = await Should.ThrowAsync<OidcException>(async () =>
|
||||||
|
await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
exception.ErrorCode.ShouldBe("invalid_grant");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AProviderThatDoesNotOfferS256_IsRejected()
|
||||||
|
{
|
||||||
|
// Falling back to 'plain' is not an option for a public client, so this is fatal rather than
|
||||||
|
// a silent downgrade.
|
||||||
|
using var plainOnly = new StubProvider(advertiseS256: false);
|
||||||
|
plainOnly.StubTokenResponse();
|
||||||
|
|
||||||
|
var client = new OidcClient(
|
||||||
|
http,
|
||||||
|
new FakeBrowser(),
|
||||||
|
TimeProvider.System,
|
||||||
|
OptionsFor(plainOnly.Authority));
|
||||||
|
|
||||||
|
var exception = await Should.ThrowAsync<OidcException>(async () =>
|
||||||
|
await client.SignInAsync(TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
exception.Message.ShouldContain("S256");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AProviderWhoseMetadataDeclaresAnotherIssuer_IsRejected()
|
||||||
|
{
|
||||||
|
// RFC 8414 §3.3. Without this check an attacker-controlled authority can serve metadata
|
||||||
|
// pointing at its own token endpoint, and the client hands over the code believing it is
|
||||||
|
// talking to the real provider.
|
||||||
|
using var lying = new StubProvider(issuerOverride: "https://someone-else.example");
|
||||||
|
|
||||||
|
var client = new OidcClient(
|
||||||
|
http,
|
||||||
|
new FakeBrowser(),
|
||||||
|
TimeProvider.System,
|
||||||
|
OptionsFor(lying.Authority));
|
||||||
|
|
||||||
|
var exception = await Should.ThrowAsync<OidcException>(async () =>
|
||||||
|
await client.SignInAsync(TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
exception.Message.ShouldContain("issuer");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task APlaintextAuthority_IsRejectedWhenHttpsIsRequired()
|
||||||
|
{
|
||||||
|
var client = new OidcClient(
|
||||||
|
http,
|
||||||
|
new FakeBrowser(),
|
||||||
|
TimeProvider.System,
|
||||||
|
new OidcClientOptions
|
||||||
|
{
|
||||||
|
Authority = provider.Authority,
|
||||||
|
ClientId = "dodossh-desktop",
|
||||||
|
RequireHttpsMetadata = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
var exception = await Should.ThrowAsync<OidcException>(async () =>
|
||||||
|
await client.SignInAsync(TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
exception.Message.ShouldContain("HTTPS");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Refresh ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_ExchangesTheToken()
|
||||||
|
{
|
||||||
|
provider.StubTokenResponse(accessToken: "fresh-access", refreshToken: "rotated-refresh");
|
||||||
|
|
||||||
|
var tokens = await CreateClient(new FakeBrowser())
|
||||||
|
.RefreshAsync("old-refresh", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
tokens.AccessToken.ShouldBe("fresh-access");
|
||||||
|
tokens.RefreshToken.ShouldBe("rotated-refresh");
|
||||||
|
|
||||||
|
var form = provider.LastTokenRequestForm();
|
||||||
|
form["grant_type"].ShouldBe("refresh_token");
|
||||||
|
form["refresh_token"].ShouldBe("old-refresh");
|
||||||
|
form["client_id"].ShouldBe("dodossh-desktop");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_CarriesForwardATokenTheProviderDidNotRotate()
|
||||||
|
{
|
||||||
|
// Many providers omit refresh_token when they do not rotate. Returning null there would make
|
||||||
|
// the caller discard a token that is still valid and sign the user out on the next launch.
|
||||||
|
provider.StubTokenResponse(refreshToken: null);
|
||||||
|
|
||||||
|
var tokens = await CreateClient(new FakeBrowser())
|
||||||
|
.RefreshAsync("still-good", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
tokens.RefreshToken.ShouldBe("still-good");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnAbsentExpiresIn_YieldsAShortLifetimeRatherThanNone()
|
||||||
|
{
|
||||||
|
// Treating "unspecified" as "never expires" hands the caller a token it never refreshes.
|
||||||
|
provider.StubTokenResponse(expiresInSeconds: null);
|
||||||
|
|
||||||
|
var before = TimeProvider.System.GetUtcNow();
|
||||||
|
|
||||||
|
var tokens = await CreateClient(new FakeBrowser())
|
||||||
|
.RefreshAsync("refresh", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
tokens.ExpiresAtUtc.ShouldBeGreaterThan(before);
|
||||||
|
tokens.ExpiresAtUtc.ShouldBeLessThan(before.AddMinutes(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- The key binding ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthorizeKeyBinding_SendsTheNonceAndForcesAFreshLogin()
|
||||||
|
{
|
||||||
|
// The primary public-key trust anchor. prompt=login means the assertion attests to a user
|
||||||
|
// present now, not to a session opened at some unknown earlier time.
|
||||||
|
var nonce = NonceFor("alice");
|
||||||
|
provider.StubTokenResponse(includeIdToken: true, idTokenNonce: nonce);
|
||||||
|
|
||||||
|
var browser = new FakeBrowser();
|
||||||
|
|
||||||
|
var idToken = await CreateClient(browser)
|
||||||
|
.AuthorizeKeyBindingAsync(nonce, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
idToken.ShouldNotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
|
var parameters = browser.AuthorizeParameters;
|
||||||
|
parameters["nonce"].ShouldBe(nonce);
|
||||||
|
parameters["prompt"].ShouldBe("login");
|
||||||
|
|
||||||
|
await browser.CallbackDelivery!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthorizeKeyBinding_RequestsOnlyOpenid()
|
||||||
|
{
|
||||||
|
// A second refresh token would be one more long-lived credential to store for no benefit.
|
||||||
|
var nonce = NonceFor("alice");
|
||||||
|
provider.StubTokenResponse(includeIdToken: true, idTokenNonce: nonce);
|
||||||
|
|
||||||
|
var browser = new FakeBrowser();
|
||||||
|
|
||||||
|
await CreateClient(browser).AuthorizeKeyBindingAsync(nonce, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
browser.AuthorizeParameters["scope"].ShouldBe("openid");
|
||||||
|
|
||||||
|
await browser.CallbackDelivery!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthorizeKeyBinding_RejectsATokenBindingADifferentStatement()
|
||||||
|
{
|
||||||
|
// Accepting it would store evidence that verifies against keys we are not publishing, which
|
||||||
|
// is precisely the fabrication the binding exists to prevent.
|
||||||
|
var requested = NonceFor("alice");
|
||||||
|
var somethingElse = NonceFor("bob");
|
||||||
|
|
||||||
|
provider.StubTokenResponse(includeIdToken: true, idTokenNonce: somethingElse);
|
||||||
|
|
||||||
|
var exception = await Should.ThrowAsync<OidcException>(async () =>
|
||||||
|
await CreateClient(new FakeBrowser())
|
||||||
|
.AuthorizeKeyBindingAsync(requested, TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
exception.Message.ShouldContain("nonce");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthorizeKeyBinding_RejectsATokenWithNoNonceAtAll()
|
||||||
|
{
|
||||||
|
var nonce = NonceFor("alice");
|
||||||
|
provider.StubTokenResponse(includeIdToken: true, idTokenNonce: null);
|
||||||
|
|
||||||
|
await Should.ThrowAsync<OidcException>(async () =>
|
||||||
|
await CreateClient(new FakeBrowser())
|
||||||
|
.AuthorizeKeyBindingAsync(nonce, TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthorizeKeyBinding_RejectsAResponseWithNoIdToken()
|
||||||
|
{
|
||||||
|
var nonce = NonceFor("alice");
|
||||||
|
provider.StubTokenResponse(includeIdToken: false);
|
||||||
|
|
||||||
|
var exception = await Should.ThrowAsync<OidcException>(async () =>
|
||||||
|
await CreateClient(new FakeBrowser())
|
||||||
|
.AuthorizeKeyBindingAsync(nonce, TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
exception.Message.ShouldContain("ID token");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
private OidcClient CreateClient(IBrowserLauncher browser) =>
|
||||||
|
new(http, browser, TimeProvider.System, OptionsFor(provider.Authority));
|
||||||
|
|
||||||
|
private static OidcClientOptions OptionsFor(Uri authority) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Authority = authority,
|
||||||
|
ClientId = "dodossh-desktop",
|
||||||
|
|
||||||
|
// The stub serves plaintext on a loopback port.
|
||||||
|
RequireHttpsMetadata = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>A real binding nonce, from a real key statement.</summary>
|
||||||
|
private static string NonceFor(string subject) =>
|
||||||
|
KeyStatementCodec.ComputeNonce(new KeyStatementFields(
|
||||||
|
Version: 1,
|
||||||
|
Issuer: "https://idp.example",
|
||||||
|
Subject: subject,
|
||||||
|
Email: null,
|
||||||
|
EncryptionPublicKey: [.. Enumerable.Range(0, 32).Select(i => (byte)(0x40 + i))],
|
||||||
|
SigningPublicKey: [.. Enumerable.Range(0, 32).Select(i => (byte)(0x60 + i))],
|
||||||
|
KeyGeneration: 1,
|
||||||
|
CreatedAt: DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123),
|
||||||
|
DeviceName: "laptop"));
|
||||||
|
|
||||||
|
/// <summary>Recomputes an S256 challenge from a verifier, independently of PkcePair.</summary>
|
||||||
|
private static string Recompute(string? verifier) =>
|
||||||
|
System.Buffers.Text.Base64Url.EncodeToString(
|
||||||
|
System.Security.Cryptography.SHA256.HashData(
|
||||||
|
System.Text.Encoding.ASCII.GetBytes(verifier ?? string.Empty)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
using System.Buffers.Text;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using WireMock.RequestBuilders;
|
||||||
|
using WireMock.ResponseBuilders;
|
||||||
|
using WireMock.Server;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Auth.Tests;
|
||||||
|
|
||||||
|
/// <summary>An identity provider stub: discovery and a token endpoint.</summary>
|
||||||
|
internal sealed class StubProvider : IDisposable
|
||||||
|
{
|
||||||
|
private readonly WireMockServer server;
|
||||||
|
|
||||||
|
internal StubProvider(
|
||||||
|
bool advertiseS256 = true,
|
||||||
|
string? issuerOverride = null)
|
||||||
|
{
|
||||||
|
server = WireMockServer.Start();
|
||||||
|
Authority = new Uri(server.Url!.TrimEnd('/'), UriKind.Absolute);
|
||||||
|
|
||||||
|
StubDiscovery(advertiseS256, issuerOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The provider's base URL, which is also its issuer.</summary>
|
||||||
|
internal Uri Authority { get; }
|
||||||
|
|
||||||
|
/// <summary>Requests the stub received, so tests can assert on what was sent.</summary>
|
||||||
|
internal IReadOnlyList<WireMock.Logging.ILogEntry> Requests => server.LogEntries.ToList();
|
||||||
|
|
||||||
|
/// <summary>Stubs a successful token response.</summary>
|
||||||
|
internal void StubTokenResponse(
|
||||||
|
string accessToken = "access-token",
|
||||||
|
string? refreshToken = "refresh-token",
|
||||||
|
string? idTokenNonce = null,
|
||||||
|
bool includeIdToken = false,
|
||||||
|
int? expiresInSeconds = 3600)
|
||||||
|
{
|
||||||
|
var body = new JsonObject
|
||||||
|
{
|
||||||
|
["access_token"] = accessToken,
|
||||||
|
["token_type"] = "Bearer",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (expiresInSeconds is { } seconds)
|
||||||
|
{
|
||||||
|
body["expires_in"] = seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshToken is not null)
|
||||||
|
{
|
||||||
|
body["refresh_token"] = refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeIdToken)
|
||||||
|
{
|
||||||
|
body["id_token"] = BuildIdToken(idTokenNonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
server
|
||||||
|
.Given(Request.Create().WithPath("/connect/token").UsingPost())
|
||||||
|
.RespondWith(Response.Create()
|
||||||
|
.WithStatusCode(200)
|
||||||
|
.WithHeader("Content-Type", "application/json")
|
||||||
|
.WithBody(body.ToJsonString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stubs an error from the token endpoint.</summary>
|
||||||
|
internal void StubTokenError(int statusCode, string error, string description)
|
||||||
|
{
|
||||||
|
var body = new JsonObject
|
||||||
|
{
|
||||||
|
["error"] = error,
|
||||||
|
["error_description"] = description,
|
||||||
|
};
|
||||||
|
|
||||||
|
server
|
||||||
|
.Given(Request.Create().WithPath("/connect/token").UsingPost())
|
||||||
|
.RespondWith(Response.Create()
|
||||||
|
.WithStatusCode(statusCode)
|
||||||
|
.WithHeader("Content-Type", "application/json")
|
||||||
|
.WithBody(body.ToJsonString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The form body the token endpoint last received, parsed.</summary>
|
||||||
|
internal IReadOnlyDictionary<string, string> LastTokenRequestForm()
|
||||||
|
{
|
||||||
|
var entries = server.LogEntries
|
||||||
|
.Where(e => e.RequestMessage?.Path?
|
||||||
|
.EndsWith("/connect/token", StringComparison.Ordinal) == true)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (entries.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The token endpoint was never called.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = entries[^1].RequestMessage?.Body ?? string.Empty;
|
||||||
|
var form = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var pair in body.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var separator = pair.IndexOf('=', StringComparison.Ordinal);
|
||||||
|
if (separator < 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
form[Uri.UnescapeDataString(pair[..separator])] =
|
||||||
|
Uri.UnescapeDataString(pair[(separator + 1)..].Replace('+', ' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
server.Stop();
|
||||||
|
server.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a JWT-shaped ID token with an unverifiable signature.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Unsigned on purpose. The client reads the nonce without validating the signature, which OIDC
|
||||||
|
/// Core §3.1.3.7 permits for a token received directly from the token endpoint over TLS — so a
|
||||||
|
/// real signature here would test nothing the client does.
|
||||||
|
/// </remarks>
|
||||||
|
private static string BuildIdToken(string? nonce)
|
||||||
|
{
|
||||||
|
var header = new JsonObject { ["alg"] = "RS256", ["typ"] = "JWT" };
|
||||||
|
var payload = new JsonObject { ["sub"] = "alice", ["iss"] = "https://idp.example" };
|
||||||
|
|
||||||
|
if (nonce is not null)
|
||||||
|
{
|
||||||
|
payload["nonce"] = nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join('.',
|
||||||
|
Base64Url.EncodeToString(Encoding.UTF8.GetBytes(header.ToJsonString())),
|
||||||
|
Base64Url.EncodeToString(Encoding.UTF8.GetBytes(payload.ToJsonString())),
|
||||||
|
Base64Url.EncodeToString("not-a-real-signature"u8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StubDiscovery(bool advertiseS256, string? issuerOverride)
|
||||||
|
{
|
||||||
|
var document = new JsonObject
|
||||||
|
{
|
||||||
|
["issuer"] = issuerOverride ?? Authority.AbsoluteUri.TrimEnd('/'),
|
||||||
|
["authorization_endpoint"] = $"{Authority.AbsoluteUri.TrimEnd('/')}/connect/authorize",
|
||||||
|
["token_endpoint"] = $"{Authority.AbsoluteUri.TrimEnd('/')}/connect/token",
|
||||||
|
["jwks_uri"] = $"{Authority.AbsoluteUri.TrimEnd('/')}/.well-known/jwks.json",
|
||||||
|
["response_types_supported"] = new JsonArray("code"),
|
||||||
|
["id_token_signing_alg_values_supported"] = new JsonArray("RS256"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (advertiseS256)
|
||||||
|
{
|
||||||
|
document["code_challenge_methods_supported"] = new JsonArray("S256");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
document["code_challenge_methods_supported"] = new JsonArray("plain");
|
||||||
|
}
|
||||||
|
|
||||||
|
server
|
||||||
|
.Given(Request.Create().WithPath("/.well-known/openid-configuration").UsingGet())
|
||||||
|
.RespondWith(Response.Create()
|
||||||
|
.WithStatusCode(200)
|
||||||
|
.WithHeader("Content-Type", "application/json")
|
||||||
|
.WithBody(document.ToJsonString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user