fix(auth): honor required nonce validation for native sign-in
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 5e6bfd71ee8a5674882f7680c695a0ada599622f] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 24s
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 20s
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 27s
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 14s
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 1m18s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 7s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 13m45s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 10s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 13m58s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 3s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 5e6bfd71ee8a5674882f7680c695a0ada599622f] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 24s
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 20s
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 27s
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 14s
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 1m18s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 7s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 13m45s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 10s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 13m58s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 3s
Retain the authorization nonce and send it as expectedNonce only when the semantic authentication descriptor requires validation. Preserve legacy exchange bodies and refuse unknown policies without opening the browser. Add regression coverage and fail verification when no tests execute. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
1 parent
f954f11243
commit
443f6429b3
9 files changed
+142
-8
No files matched your search
@@ -13,6 +13,10 @@
|
||||
is run separately and must not be inferred from a successful unit-test run.
|
||||
Report each suite separately; duplicate package measurements must not be averaged. -->
|
||||
<RunSettings>
|
||||
<!-- A test run that executes zero tests (for example, a test assembly blocked from loading) must fail, not pass. -->
|
||||
<RunConfiguration>
|
||||
<TreatNoTestsAsError>true</TreatNoTestsAsError>
|
||||
</RunConfiguration>
|
||||
<DataCollectionRunSettings>
|
||||
<DataCollectors>
|
||||
<DataCollector friendlyName="XPlat Code Coverage">
|
||||
|
||||
@@ -10,6 +10,8 @@ Older pairings without a bound QQQ user require sign-in and association. Routine
|
||||
|
||||
The installed browser callback is exactly `io.qrun.qqq-print-agent:/oauth/callback`. The host must advertise and approve this redirect for its configured QQQ OAuth2 session client; the separate public bearer client is not substituted for the server-managed session exchange. MSI registers the stable scheme independently of display branding and removes its owned registration on uninstall. The browser launches the quoted installed CLI callback helper, which forwards only to an active companion attempt in the same Windows user/session, after checking the companion executable. The companion validates the single-use state and PKCE exchange using the standard QQQ grant. No callback starts a new sign-in attempt. No provider registration, registry changes or installed browser sign-in are performed by source tests.
|
||||
|
||||
**Shared server with the Android scanner.** One QQQ host and one OAuth2 provider/`clientId` can serve both clients. The native grant's `redirectUris` must contain both exact callbacks, `io.qrun.qqq-print-agent:/oauth/callback` (print agent) and `io.qrun.mobile.scanner.oauth://callback/oauth2redirect` (scanner), and the provider must allow both exactly (no wildcards). The host must also advertise the `api` and `android` client channels and both the `printing` and `mobile-scanners` extensions. Nonce handling is negotiated per grant: when the descriptor has `nonceValidation: REQUIRED` (which the scanner demands), the agent sends that attempt's own authorization nonce as `expectedNonce` in the `manageSession` exchange. When the field is absent (older servers) the exchange body is unchanged. Any other non-empty value is refused as a server configuration problem before a browser opens. The nonce is never taken from the callback, regenerated or logged. Human permissions (`printing_admin` versus the scanner's `mobile.*`) and device credentials stay separate. Validation against a real shared Authentik host is pending; this is not released.
|
||||
|
||||
Normal CLI `printers`, `enroll`, `expose`, `run` and `sign-out` commands use the installed authenticated service. `run` requests a connection refresh; the service owns unattended printing. Direct bearer-based foreground diagnostics require explicit `--legacy-compat` and a separate user data directory; they cannot use `--service`. They are a compatibility diagnostic, not the app's sign-in or fallback path.
|
||||
|
||||
The server and its identity provider govern session lifetime, renewal and current permissions. Unattended service operation does not promise an indefinite authorization lifetime: expiration, revocation or a policy change can require a new sign-in. Configure the advertised native session grant and approved callback for each deployment.
|
||||
|
||||
+16
-2
@@ -1,6 +1,20 @@
|
||||
# Session state — 2026-11-04
|
||||
# Session state — 2026-11-05
|
||||
|
||||
## Current status: released and installed — 2026-11-04
|
||||
## Active scoped work: required-nonce native sign-in fix — 2026-11-05
|
||||
|
||||
Branch `fix/native-login-required-nonce`, integrated on main `f954f11`, makes native sign-in honor the server's `NativeGrant.NonceValidation` policy. One nonce is created per authorization attempt and kept in the attempt only when the policy is `REQUIRED`; the same value is sent as `expectedNonce` at `manageSession`. An absent (legacy) policy keeps the exact old four-field body, and an unknown non-empty policy is refused before any provider or browser step. The callback never supplies the nonce, and state, issuer, PKCE, single-use and expiry checks are unchanged.
|
||||
|
||||
The shared-server scanner Authentik requires this fix. The prior stable printing release below remains historical accepted evidence and is not reopened.
|
||||
|
||||
Observed so far:
|
||||
|
||||
- Previous-base solution build and 333 CLI tests passed locally. Core tests executed 0 tests because Windows security blocked the assembly; that is hosted-CI-only and is **not a pass**.
|
||||
- The integrated NativeCI Release build passed with zero warnings/errors after extracting the code-exchange body into a private helper to meet the existing method-length limit. All 352 Windows/CLI tests passed with no failures/skips; the source-notice check passed for 300 tracked files. Core.Tests was excluded as required on this workstation.
|
||||
- Root verified, in an isolated shared scanner/Printing 1.0.0 fixture with `REQUIRED` nonce, that both exact native callback registrations and real server HTTPS discovery work. This is discovery only, not native login proof.
|
||||
|
||||
Pending: hosted CI (including Core.Tests), real shared-provider native login acceptance, and the final published build.
|
||||
|
||||
## Historical: released and installed — 2026-11-04
|
||||
|
||||
Signed Windows **1.0.1** is published on primary Gitea and GitHub, from source `5bf00f3291923f7afb34c6cce61276e69649689b`. [Release run 41532214447](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41532214447) passed its protected gates, OIDC signing and publication. Checksums, signatures and provenance were verified. The November 3 local MSI installation exited 0; app/service/CLI matched 1.0.1 (`1.0.1.65535`) and the service was Running. This docs-only update does not imply a new runtime check.
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# TODO
|
||||
|
||||
## Active: required-nonce native sign-in fix — 2026-11-05
|
||||
|
||||
The stable printing release below remains accepted historical evidence. The shared-server scanner Authentik requires this fix (`fix/native-login-required-nonce`, integrated on main `f954f11`).
|
||||
|
||||
- [x] Implement per-attempt nonce kept only under `REQUIRED`, sent as `expectedNonce`; legacy four-field body preserved; unknown policy refused before provider/browser.
|
||||
- [x] Root verified exact native callback registrations and real server HTTPS discovery in an isolated shared scanner/Printing 1.0.0 fixture with `REQUIRED` nonce (discovery only, not login proof).
|
||||
- [x] Integrated NativeCI Release build: zero warnings/errors; 352 Windows/CLI tests passed with no failures/skips; source notices passed for 300 tracked files. Existing method-length limit retained; Core.Tests excluded locally.
|
||||
- [ ] Hosted CI including Core.Tests. Core tests executed 0 locally (Windows security) and are hosted-CI-only; that is not a pass.
|
||||
- [ ] Real shared-provider native login acceptance.
|
||||
- [ ] Final published build.
|
||||
|
||||
## Closed product release — 2026-11-04
|
||||
|
||||
- [x] Publish signed Windows 1.0.1 on Gitea and the GitHub updater feed; release run 41532214447 passed its gates and Azure OIDC signing.
|
||||
|
||||
@@ -10,7 +10,7 @@ The remote-control adapter accepts only inventory refresh and cancellation of on
|
||||
|
||||
The native QQQ session adapter requires discovery before credentials and never treats a native control 404 as legacy permission. After support is negotiated, 404 does not remove restrictions. Authorization denial becomes authorization-required, without inferring that every 401/403 proves revocation. New claims wait for an authenticated, bound QQQ user and valid control state. Existing installations without a protected QQQ session wait for explicit sign-in; device proof alone cannot authorize normal service printing. Explicit isolated CLI diagnostics may use the compatibility bearer adapter with `--legacy-compat`; it is not a fallback from native authentication.
|
||||
|
||||
The service stores only the QQQ session cookie and stable authoritative identity under service-identity DPAPI, and periodically resumes the standard session. Passwords stay in the companion; provider refresh/access tokens remain server-side. Normal CLI operations use the existing authenticated service pipe. The private browser callback is fixed and MSI-owned, forwarding bounded data only into an active same-user/session companion inbox after executable verification. It carries a short-lived authorization code, never a session cookie or PKCE verifier. Core validates exact advertised redirect, random state, PKCE and single-use expiry; no callback creates an authentication attempt. Provider registration, installed callback delivery and long-duration renewal remain deployment qualifications.
|
||||
The service stores only the QQQ session cookie and stable authoritative identity under service-identity DPAPI, and periodically resumes the standard session. Passwords stay in the companion; provider refresh/access tokens remain server-side. Normal CLI operations use the existing authenticated service pipe. The private browser callback is fixed and MSI-owned, forwarding bounded data only into an active same-user/session companion inbox after executable verification. It carries a short-lived authorization code, never a session cookie or PKCE verifier. Core validates exact advertised redirect, random state, PKCE and single-use expiry; when the server advertises `nonceValidation: REQUIRED` it also sends only that attempt's own authorization nonce as `expectedNonce` (never a callback-supplied or regenerated value) and refuses any other non-empty nonce policy; no callback creates an authentication attempt. Provider registration, installed callback delivery and long-duration renewal remain deployment qualifications.
|
||||
|
||||
Realtime frames and queues have administrator-configured byte/count bounds validated against declared client ceilings. Defaults are 1 MiB per complete frame, 64 queued frames and 4 MiB queued bytes. Fragmentation does not bypass the complete-message limit. The journal admits at most 4,096 command records and 128 retained connection states, failing closed when capacity is exhausted. A durable marker precedes any effect. Crash recovery produces OUTCOME_UNKNOWN; duplicate delivery reuses the exact saved receipt rather than repeating cancellation. No raw executable, printer name or arbitrary spool ID comes from the server: CANCEL_SPOOL resolves its job ID through the current connection's local receipt and verifies the exact QQQ document at the Windows spooler boundary.
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ public sealed class QqqAuthenticationMetadata
|
||||
public string Prompt { get; init; } = "";
|
||||
public string Issuer { get; init; } = "";
|
||||
public string DiscoveryUrl { get; init; } = "";
|
||||
|
||||
/// <summary>The server's nonce policy for the code exchange. Absent on older servers; <see cref="NonceRequired"/> means the exchange must carry the attempt's own nonce as expectedNonce.</summary>
|
||||
public string? NonceValidation { get; init; }
|
||||
public const string NonceRequired = "REQUIRED";
|
||||
public IReadOnlyList<string> RedirectUris { get; init; } = [];
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,9 @@ namespace Qqq.PrintAgent.Core.Authentication;
|
||||
public sealed class QqqBrowserLogin
|
||||
{
|
||||
/// <summary>Records one sign-in attempt: the address to open in the browser, the registered redirect, the random state and PKCE verifier that only this attempt knows, the expected issuer, and when it started so it can expire.</summary>
|
||||
internal QqqBrowserLogin(Uri authorizationUri, Uri redirectUri, string state, string verifier, string issuer, long startedAt)
|
||||
internal QqqBrowserLogin(Uri authorizationUri, Uri redirectUri, string state, string verifier, string issuer, long startedAt, string? expectedNonce = null)
|
||||
{
|
||||
ExpectedNonce = expectedNonce;
|
||||
AuthorizationUri = authorizationUri;
|
||||
RedirectUri = redirectUri;
|
||||
State = state;
|
||||
@@ -26,10 +27,13 @@ public sealed class QqqBrowserLogin
|
||||
internal string State { get; }
|
||||
internal string Verifier { get; }
|
||||
internal string Issuer { get; }
|
||||
|
||||
/// <summary>This attempt's own authorization nonce, kept only when the server advertised that it validates it at exchange; null otherwise. Never taken from a callback.</summary>
|
||||
internal string? ExpectedNonce { get; }
|
||||
internal long StartedAt { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns a fixed redacted label, so logging or displaying an attempt can never reveal its state or verifier.</summary>
|
||||
/// <summary>Returns a fixed redacted label, so logging or displaying an attempt can never reveal its state, nonce or verifier.</summary>
|
||||
public override string ToString() => "QQQ browser sign-in attempt (redacted)";
|
||||
}
|
||||
@@ -35,6 +35,7 @@ public sealed partial class QqqSessionClient
|
||||
Uri authorizationUri = await ProviderAuthorizationEndpointAsync(grant, cancellationToken).ConfigureAwait(false);
|
||||
string state = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
string verifier = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
string nonce = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
var query = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["client_id"] = metadata.Values!.ClientId,
|
||||
@@ -42,7 +43,7 @@ public sealed partial class QqqSessionClient
|
||||
["redirect_uri"] = redirectUri.AbsoluteUri,
|
||||
["scope"] = metadata.Values.Scopes,
|
||||
["state"] = state,
|
||||
["nonce"] = Base64Url(RandomNumberGenerator.GetBytes(32)),
|
||||
["nonce"] = nonce,
|
||||
["prompt"] = grant.Prompt,
|
||||
["code_challenge_method"] = "S256",
|
||||
["code_challenge"] = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))),
|
||||
@@ -52,7 +53,9 @@ public sealed partial class QqqSessionClient
|
||||
Query = string.Join("&", query.Select(pair => Uri.EscapeDataString(pair.Key) + "=" + Uri.EscapeDataString(pair.Value))),
|
||||
};
|
||||
ClearSession();
|
||||
_browserLogin = new QqqBrowserLogin(builder.Uri, redirectUri, state, verifier, grant.Issuer, _timeProvider.GetTimestamp());
|
||||
// The per-attempt nonce is kept only when the server advertised REQUIRED; legacy servers keep the exact old exchange.
|
||||
_browserLogin = new QqqBrowserLogin(builder.Uri, redirectUri, state, verifier, grant.Issuer, _timeProvider.GetTimestamp(),
|
||||
string.Equals(grant.NonceValidation, QqqAuthenticationMetadata.NativeGrant.NonceRequired, StringComparison.Ordinal) ? nonce : null);
|
||||
_diagnostics.Info($"Browser sign-in attempt started: the authorization page is {QqqSessionDiagnostics.Describe(builder.Uri)} (query not logged); waiting for the callback, which expires after five minutes.");
|
||||
return _browserLogin;
|
||||
}
|
||||
@@ -175,7 +178,7 @@ public sealed partial class QqqSessionClient
|
||||
}
|
||||
_diagnostics.Info("Browser callback validated; exchanging the authorization code with QQQ for a server-managed session.");
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/manageSession"), HttpMethod.Post,
|
||||
JsonBody(new { code, codeVerifier = attempt.Verifier, redirectUri = attempt.RedirectUri.AbsoluteUri, authenticationFlow = QqqAuthenticationMetadata.NativeSessionFlow }),
|
||||
JsonBody(NativeCodeExchange(attempt, code)),
|
||||
MetadataLimit, null, null, null, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
AdoptManagedSession(response);
|
||||
@@ -188,6 +191,17 @@ public sealed partial class QqqSessionClient
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Builds the manageSession code-exchange body. A legacy attempt (no stored nonce) keeps the exact four-field body; an attempt
|
||||
/// made under the server's REQUIRED policy adds that attempt's own nonce as expectedNonce. The nonce comes only from the attempt,
|
||||
/// never from the callback, and is not logged.
|
||||
/// </summary>
|
||||
private static object NativeCodeExchange(QqqBrowserLogin attempt, string code) => attempt.ExpectedNonce is null
|
||||
? new { code, codeVerifier = attempt.Verifier, redirectUri = attempt.RedirectUri.AbsoluteUri, authenticationFlow = QqqAuthenticationMetadata.NativeSessionFlow }
|
||||
: new { code, codeVerifier = attempt.Verifier, redirectUri = attempt.RedirectUri.AbsoluteUri, authenticationFlow = QqqAuthenticationMetadata.NativeSessionFlow, expectedNonce = attempt.ExpectedNonce };
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the server's native-session grant is one this agent can use safely: an HTTPS server with OAuth2, the expected
|
||||
/// flow, S256 and a forced login prompt, HTTPS issuer and discovery addresses, a client identifier, the openid scope, and a
|
||||
@@ -212,6 +226,9 @@ public sealed partial class QqqSessionClient
|
||||
{ problems.Add($"code challenge method is '{grant.CodeChallengeMethod}', expected S256"); }
|
||||
if(!string.Equals(grant.Prompt, "login", StringComparison.Ordinal))
|
||||
{ problems.Add($"prompt is '{grant.Prompt}', expected login"); }
|
||||
// Absent means a legacy server; any other value than REQUIRED is a policy this client cannot honor, so refuse before any provider or browser step.
|
||||
if(!string.IsNullOrEmpty(grant.NonceValidation) && !string.Equals(grant.NonceValidation, QqqAuthenticationMetadata.NativeGrant.NonceRequired, StringComparison.Ordinal))
|
||||
{ problems.Add("nonce validation policy is not one this client supports (expected REQUIRED or absent)"); }
|
||||
if(!TryHttps(grant.Issuer, out _))
|
||||
{ problems.Add($"issuer '{grant.Issuer}' is not a plain HTTPS address"); }
|
||||
if(!TryHttps(grant.DiscoveryUrl, out _))
|
||||
|
||||
@@ -450,6 +450,7 @@ public sealed class QqqSessionAuthenticationTests
|
||||
Assert.Null(exchange.DeviceToken);
|
||||
using var body = JsonDocument.Parse(exchange.Body!);
|
||||
Assert.Equal(4, body.RootElement.EnumerateObject().Count());
|
||||
Assert.False(body.RootElement.TryGetProperty("expectedNonce", out _));
|
||||
Assert.Equal("one-time-code", body.RootElement.GetProperty("code").GetString());
|
||||
Assert.Equal(QqqAuthenticationMetadata.NativeSessionFlow, body.RootElement.GetProperty("authenticationFlow").GetString());
|
||||
string verifier = body.RootElement.GetProperty("codeVerifier").GetString()!;
|
||||
@@ -499,6 +500,66 @@ public sealed class QqqSessionAuthenticationTests
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// When the server descriptor advertises nonceValidation REQUIRED, the exchange carries expectedNonce equal to the nonce in this
|
||||
/// attempt's authorization URL, even if the callback tries to supply a different nonce, and the redacted attempt text does not reveal it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RequiredNonceValidationSendsTheAttemptsOwnAuthorizationNonce()
|
||||
{
|
||||
using Handler handler = NonceHandler("REQUIRED");
|
||||
handler.Add("{}", cookie: "sessionUUID=opaque-session; Path=/; HttpOnly; Secure");
|
||||
using var client = new QqqSessionClient(Origin, handler);
|
||||
QqqBrowserLogin attempt = await client.BeginBrowserLoginAsync(Redirect);
|
||||
Dictionary<string, string> authorization = Query(attempt.AuthorizationUri);
|
||||
Assert.DoesNotContain(authorization["nonce"], attempt.ToString(), StringComparison.Ordinal);
|
||||
await client.CompleteBrowserLoginAsync(attempt, new Uri(Callback(attempt, authorization["state"]).AbsoluteUri + "&nonce=callback-supplied-nonce"));
|
||||
using var body = JsonDocument.Parse(handler.Requests[3].Body!);
|
||||
Assert.Equal(5, body.RootElement.EnumerateObject().Count());
|
||||
Assert.Equal(authorization["nonce"], body.RootElement.GetProperty("expectedNonce").GetString());
|
||||
Assert.NotEqual("callback-supplied-nonce", body.RootElement.GetProperty("expectedNonce").GetString(), StringComparer.Ordinal);
|
||||
Assert.Equal("one-time-code", body.RootElement.GetProperty("code").GetString());
|
||||
Assert.Equal("opaque-session", client.ExportCredential().SessionToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A nonce policy the client does not understand is refused as a configuration problem after the two public requests, before the provider is contacted, so no browser opens and nothing is exchanged.</summary>
|
||||
[Theory]
|
||||
[InlineData("OPTIONAL")]
|
||||
[InlineData("required")]
|
||||
[InlineData("NONE")]
|
||||
[InlineData(" ")]
|
||||
public async Task UnknownNonceValidationPolicyIsRefusedBeforeBrowserOrExchange(string policy)
|
||||
{
|
||||
using var handler = new Handler();
|
||||
handler.Add(Discovery);
|
||||
handler.Add(OAuthWithNonce(policy));
|
||||
using var client = new QqqSessionClient(Origin, handler);
|
||||
QqqSessionException exception = await Assert.ThrowsAsync<QqqSessionException>(() => client.BeginBrowserLoginAsync(Redirect));
|
||||
Assert.Equal(QqqSessionException.FailureKind.ConfigurationRequired, exception.Failure);
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
Assert.All(handler.Requests, AssertPublic);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>With the nonce required, a callback carrying the wrong state is still refused without an exchange and consumes the attempt, so the correct callback cannot be replayed afterwards.</summary>
|
||||
[Fact]
|
||||
public async Task RequiredNonceDoesNotWeakenStateAndReplayProtection()
|
||||
{
|
||||
using Handler handler = NonceHandler("REQUIRED");
|
||||
using var client = new QqqSessionClient(Origin, handler);
|
||||
QqqBrowserLogin attempt = await client.BeginBrowserLoginAsync(Redirect);
|
||||
string state = Query(attempt.AuthorizationUri)["state"];
|
||||
await Assert.ThrowsAsync<QqqSessionException>(() => client.CompleteBrowserLoginAsync(attempt, Callback(attempt, "wrong")));
|
||||
await Assert.ThrowsAsync<QqqSessionException>(() => client.CompleteBrowserLoginAsync(attempt, Callback(attempt, state)));
|
||||
Assert.Equal(3, handler.Requests.Count);
|
||||
Assert.False(client.IsAuthenticated);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>If the provider's own discovery document names a different issuer than the server advertised, no browser URL is produced, which stops a swapped or spoofed identity provider.</summary>
|
||||
[Fact]
|
||||
public async Task DiscoveryIssuerMismatchCannotLaunchBrowser()
|
||||
@@ -603,6 +664,23 @@ public sealed class QqqSessionAuthenticationTests
|
||||
|
||||
|
||||
|
||||
/// <summary>The native browser-login description with an explicit nonceValidation value added to the grant.</summary>
|
||||
private static string OAuthWithNonce(string policy) => OAuth.Replace("\"prompt\":\"login\"", "\"prompt\":\"login\",\"nonceValidation\":\"" + policy + "\"", StringComparison.Ordinal);
|
||||
|
||||
|
||||
|
||||
/// <summary>A fake server whose native grant advertises the given nonce policy, followed by the provider's discovery document.</summary>
|
||||
private static Handler NonceHandler(string policy)
|
||||
{
|
||||
var handler = new Handler();
|
||||
handler.Add(Discovery);
|
||||
handler.Add(OAuthWithNonce(policy));
|
||||
handler.Add(Provider);
|
||||
return handler;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A fake server pre-loaded with discovery, the native browser-login description and the provider's discovery document, the three public replies before a browser sign-in starts.</summary>
|
||||
private static Handler OAuthHandler()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user