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))); }