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:
@@ -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