using System.Collections.Specialized;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Auth.Tests;
///
/// The authorization code flow end to end, and the rejections that make it safe.
///
///
/// 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.
///
public sealed class OidcClientTests : IDisposable
{
private readonly StubProvider provider = new();
private readonly HttpClient http = new();
///
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(StringComparer.Ordinal)
{
["code"] = "attacker-code",
["state"] = "not-the-state-we-sent",
});
var exception = await Should.ThrowAsync(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(StringComparer.Ordinal)
{
["code"] = "attacker-code",
["state"] = "wrong",
});
await Should.ThrowAsync(async () =>
await CreateClient(browser).SignInAsync(TestContext.Current.CancellationToken));
Should.Throw(() => provider.LastTokenRequestForm());
}
[Fact]
public async Task AProviderRefusal_SurfacesWithItsErrorCode()
{
var browser = new FakeBrowser(_ => new Dictionary(StringComparer.Ordinal)
{
["error"] = "access_denied",
["error_description"] = "The user said no",
});
var exception = await Should.ThrowAsync(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(StringComparer.Ordinal)
{
["state"] = parameters["state"] ?? string.Empty,
});
await Should.ThrowAsync(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(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(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(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(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(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(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(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,
};
/// A real binding nonce, from a real key statement.
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"));
/// Recomputes an S256 challenge from a verifier, independently of PkcePair.
private static string Recompute(string? verifier) =>
System.Buffers.Text.Base64Url.EncodeToString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.ASCII.GetBytes(verifier ?? string.Empty)));
}