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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user