Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a695996091
|
||
|
|
5d4b7f43ce
|
No files matched your search
@@ -1,68 +0,0 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['v*']
|
||||
jobs:
|
||||
windows:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/windows.yaml@v1
|
||||
secrets: inherit
|
||||
with:
|
||||
runner: qqq-windows-amd64
|
||||
commands: |
|
||||
$temp = $env:RUNNER_TEMP ?? [IO.Path]::GetTempPath()
|
||||
# Protocol fixtures pinned to the revision the verification expects.
|
||||
$protocol = Join-Path $temp "qqq-print-protocol-$env:GITHUB_RUN_ID"
|
||||
git clone --quiet https://git.kof22.com/QRun-IO/qqq-print-protocol.git $protocol
|
||||
git -C $protocol checkout --quiet ac835f8b9396b641b525669249fdb4a1e2a50c99
|
||||
pwsh -NoProfile -File ./build/Verify.ps1 -ProtocolFixtures (Join-Path $protocol fixtures)
|
||||
pwsh -NoProfile -File ./build/Test-WindowsSdkInventory.ps1 -VerifyPackages
|
||||
pwsh -NoProfile -File ./build/Test-BrandedInstaller.ps1
|
||||
pwsh -NoProfile -File ./build/Test-PackagingInputs.ps1
|
||||
# Unsigned MSIs: the base product plus one branded installer per edition. A vX.Y.Z[-rc.N] tag sets the version.
|
||||
$release = if ($env:GITHUB_REF_TYPE -eq 'tag') { $env:GITHUB_REF_NAME.TrimStart('v') } else { '1.2.1' }
|
||||
$product = $release -replace '-.*$', ''
|
||||
pwsh -NoProfile -File ./build/Build-Installer.ps1 -ProductVersion $product -ReleaseVersion $release
|
||||
$installers = @('QqqPrintAgent')
|
||||
foreach ($folder in Get-ChildItem -LiteralPath editions -Directory | Sort-Object Name) {
|
||||
pwsh -NoProfile -File ./build/Build-Installer.ps1 -ProductVersion $product -ReleaseVersion $release -Edition $folder.Name
|
||||
$installers += (Get-Content -LiteralPath (Join-Path $folder.FullName 'edition.json') -Raw | ConvertFrom-Json).assetPrefix
|
||||
}
|
||||
New-Item -ItemType Directory -Path artifacts/release -Force | Out-Null
|
||||
foreach ($prefix in $installers) {
|
||||
Copy-Item -LiteralPath "artifacts/installer/$prefix-$product-x64.msi" -Destination "artifacts/release/$prefix-v$release-x64.msi"
|
||||
}
|
||||
Copy-Item -LiteralPath build/Install-Branded.ps1, docs/branding.example.json -Destination artifacts/release/
|
||||
# PowerShell scripts: analyzer and Pester rejection-path tests with pinned modules saved to the job's temp directory.
|
||||
$modules = Join-Path $temp "psmodules-$env:GITHUB_RUN_ID"
|
||||
New-Item -ItemType Directory -Force -Path $modules | Out-Null
|
||||
Save-PSResource -Name PSScriptAnalyzer -Version 1.25.0 -Repository PSGallery -TrustRepository -Path $modules
|
||||
Save-PSResource -Name Pester -Version 5.7.1 -Repository PSGallery -TrustRepository -Path $modules
|
||||
$env:PSModulePath = "$modules$([IO.Path]::PathSeparator)$env:PSModulePath"
|
||||
pwsh -NoProfile -File ./build/Analyze-Scripts.ps1
|
||||
Import-Module Pester -RequiredVersion 5.7.1
|
||||
# The Pester suites assert on child processes' exit codes themselves.
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
$pester = New-PesterConfiguration
|
||||
$pester.Run.Path = @('build/Assert-Coverage.Tests.ps1', 'build/Analyze-Scripts.Tests.ps1', 'build/Install-Branded.Tests.ps1', 'build/Test-WindowsSdkInventory.Tests.ps1')
|
||||
$pester.Run.PassThru = $true
|
||||
if ((Invoke-Pester -Configuration $pester).Result -ne 'Passed') { throw 'Pester tests failed' }
|
||||
artifacts: |
|
||||
artifacts/release/*.msi
|
||||
artifacts/release/Install-Branded.ps1
|
||||
artifacts/release/branding.example.json
|
||||
scripts:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/node.yaml@v1
|
||||
with:
|
||||
node-version: '24'
|
||||
uv: true
|
||||
install: npm ci --ignore-scripts --no-audit --no-fund
|
||||
commands: |
|
||||
uv sync --locked
|
||||
uv run --locked --no-sync python .github/scripts/test-script-quality.py
|
||||
uv run --locked --no-sync python .github/scripts/check-script-quality.py
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
python3 .github/scripts/test-source-headers.py
|
||||
python3 .github/scripts/check-source-headers.py
|
||||
node --test tools/virtual-agent/test/virtual-agent.test.mjs
|
||||
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
Sign-out now ends the identity provider's browser session as well as the QQQ session, and every browser
|
||||
sign-in sends `max_age=0` beside `prompt=login`, so the next sign-in always asks for credentials (the provider
|
||||
must stay invisible to users). The companion's Sign out asks the service to log out with the application's
|
||||
registered callback; when the server advertises `nativeSessionLogout` (UI API 1.10 or later) and lists that
|
||||
callback, the server returns the provider's end-session data, Core checks it against the issuer QQQ advertises
|
||||
and the provider's own discovery (HTTPS end-session endpoint on the issuer's origin, exact callback, bounded
|
||||
hint), and the companion opens it in the browser and waits for the state-matched return. A server without the
|
||||
capability gets the ordinary logout, and the log says the provider session was not ended. The command-line
|
||||
`sign-out` cannot open the companion's callback inbox, so it still ends only the QQQ session and says so.
|
||||
No change to pairing, history or the printing protocol.
|
||||
|
||||
## 1.2.1
|
||||
|
||||
Release-pipeline fix only; same source as 1.2.0 otherwise. The stable release workflow's per-edition
|
||||
|
||||
@@ -188,30 +188,7 @@ if($CertificateThumbprint)
|
||||
}
|
||||
|
||||
# WiX incremental inputs do not reliably invalidate a changed version/payload path.
|
||||
# WiX validates the MSI (ICE checks) through the Windows Installer service. A runner that logs on as a batch account cannot
|
||||
# open that service, so WiX fails every ICE action with WIX0217 ("The Windows Installer Service could not be accessed").
|
||||
# Validate whenever the service is reachable. Skip validation only when it is not, with a loud warning, and never for a
|
||||
# signed release build: that must be built on a host where validation works.
|
||||
$installerValidationArguments = @()
|
||||
try
|
||||
{
|
||||
$installerService = Get-Service -Name msiserver -ErrorAction Stop
|
||||
if($installerService.Status -ne 'Running')
|
||||
{
|
||||
$installerService.Start()
|
||||
$installerService.WaitForStatus('Running', [TimeSpan]::FromSeconds(30))
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
if($CertificateThumbprint)
|
||||
{
|
||||
throw "The Windows Installer service is not reachable, so the MSI cannot be validated; refusing to build a signed release without validation. $($_.Exception.Message)"
|
||||
}
|
||||
Write-Warning "The Windows Installer service is not reachable from this session; building the MSI WITHOUT ICE validation (not a signed release build). $($_.Exception.Message)"
|
||||
$installerValidationArguments = @('-p:SuppressValidation=true')
|
||||
}
|
||||
& dotnet build $installerProject --configuration Release -t:Rebuild '-p:RestoreLockedMode=true' "-p:ProductVersion=$ProductVersion" "-p:PayloadRoot=$payloadRoot" "-p:OutputPath=$buildDirectory\" @brandInstallerArgs @installerValidationArguments
|
||||
& dotnet build $installerProject --configuration Release -t:Rebuild '-p:RestoreLockedMode=true' "-p:ProductVersion=$ProductVersion" "-p:PayloadRoot=$payloadRoot" "-p:OutputPath=$buildDirectory\" @brandInstallerArgs
|
||||
if($LASTEXITCODE -ne 0) { throw 'MSI build failed.' }
|
||||
$builtMsi = Join-Path $buildDirectory "$msiBaseName-$ProductVersion-x64.msi"
|
||||
if(-not (Test-Path -LiteralPath $builtMsi)) { throw 'MSI output was not produced.' }
|
||||
|
||||
@@ -12,6 +12,8 @@ The native QQQ session adapter requires discovery before credentials and never t
|
||||
|
||||
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.
|
||||
|
||||
Sign-out also ends the identity provider's browser session. The companion sends only the fixed registered callback with `sign-out`; the service refuses any other value, asks QQQ to log out with `nativeLogout.postLogoutRedirectUri`, and returns the provider end-session request to the companion over the administrator-only pipe. That request carries the provider's ID token hint (sensitive, bounded to 32 KiB, no whitespace or control characters), so it is never logged, the record prints redacted, and only the provider host is logged. Core refuses the data unless the issuer equals the one QQQ advertises, the end-session endpoint is HTTPS on the issuer's origin and identical to the provider's own discovery, and the callback is the one requested. The browser return is accepted only on the exact callback with the state this sign-out generated. Browser sign-in also sends `max_age=0` beside `prompt=login`.
|
||||
|
||||
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.
|
||||
|
||||
Successful cancellation means Windows accepted the request, not that printed paper was recalled. Absent/completed jobs, ownership mismatch, failure, expiry and uncertain outcome remain distinct. Only a specific missing command receipt after expiry plus seven days retires its transport retry, preserving the exact local receipt and distinguishing retirement from server acknowledgement. Discovery/socket failures never use this exception. Acknowledged or transport-retired command history can be pruned after 30 days; unresolved current-connection evidence remains and causes explicit capacity backpressure. No global Windows queue purge is implemented.
|
||||
|
||||
@@ -126,6 +126,10 @@ public static class Program
|
||||
};
|
||||
string response = await send(request);
|
||||
Console.WriteLine(response);
|
||||
if(args[0] is "sign-out" && !response.StartsWith("Error:", StringComparison.Ordinal))
|
||||
{
|
||||
Console.WriteLine("The identity provider's browser session is not ended from the command line; use Sign out in the QQQ Print Agent app to end it too.");
|
||||
}
|
||||
return response.StartsWith("Error:", StringComparison.Ordinal) ? 1 : 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,19 @@ public interface IQqqSessionClient : IDisposable
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Ends the session like <see cref="SignOutAsync"/> and also asks the server for the identity provider's sign-out, returned for the
|
||||
/// host to open in the browser so the provider's own session ends too. Null means there is nothing to open (no session, a
|
||||
/// password server, or a server without provider sign-out); the local session is forgotten either way. The default only signs out.
|
||||
/// </summary>
|
||||
async Task<QqqProviderLogout?> SignOutWithProviderAsync(Uri postLogoutRedirectUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await SignOutAsync(cancellationToken).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the credential that lets a later start restore this session; it is the only secret the caller persists.</summary>
|
||||
QqqSessionCredential ExportCredential();
|
||||
|
||||
|
||||
@@ -46,6 +46,16 @@ public sealed class QqqApiDiscovery
|
||||
|
||||
|
||||
|
||||
/// <summary>The UI API capability that says logout can return the identity provider's end-session data.</summary>
|
||||
public const string NativeSessionLogoutFeature = "nativeSessionLogout";
|
||||
|
||||
|
||||
|
||||
/// <summary>True when the server is a plain 1.10+ release that advertises provider sign-out. The capability is additive and never inferred from native sign-in support.</summary>
|
||||
public bool SupportsNativeSessionLogout => Features?.Contains(NativeSessionLogoutFeature, StringComparer.Ordinal) == true && SupportedVersion(ApiVersion, 10);
|
||||
|
||||
|
||||
|
||||
/// <summary>True when the server advertises that it validates the native sign-in nonce at exchange.</summary>
|
||||
public bool ValidatesNativeSessionNonce => Features?.Contains(NonceValidationFeature, StringComparer.Ordinal) == true;
|
||||
|
||||
|
||||
@@ -22,6 +22,19 @@ public sealed class QqqAuthenticationMetadata
|
||||
public string BaseUrl { get; init; } = "";
|
||||
public string Scopes { get; init; } = "";
|
||||
public NativeGrant? NativeSessionLogin { get; init; }
|
||||
|
||||
/// <summary>The server's opt-in to provider sign-out (OIDC RP-initiated logout) with the exact callbacks it will accept; absent on servers that do not offer it.</summary>
|
||||
public NativeLogout? NativeSessionLogout { get; init; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The server's declaration that it can return the provider's end-session data at logout: flow name and the exact post-logout callbacks.</summary>
|
||||
public sealed class NativeLogout
|
||||
{
|
||||
public const string RpInitiatedFlow = "OIDC_RP_INITIATED_LOGOUT";
|
||||
public string Flow { get; init; } = "";
|
||||
public IReadOnlyList<string> PostLogoutRedirectUris { get; init; } = [];
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// The identity provider's own sign-out, ready for the host to open in the browser. The address carries the provider's ID token
|
||||
/// hint, so it is held only in memory, handed straight to the browser, never logged, and its label is fixed text.
|
||||
/// </summary>
|
||||
public sealed class QqqProviderLogout
|
||||
{
|
||||
/// <summary>Records the address to open, the registered address the provider returns the browser to, and the random state that binds the return to this sign-out.</summary>
|
||||
public QqqProviderLogout(Uri endSessionUri, Uri redirectUri, string state)
|
||||
{
|
||||
EndSessionUri = endSessionUri ?? throw new ArgumentNullException(nameof(endSessionUri));
|
||||
RedirectUri = redirectUri ?? throw new ArgumentNullException(nameof(redirectUri));
|
||||
State = state ?? throw new ArgumentNullException(nameof(state));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The provider's end-session address including the sensitive ID token hint. Only open it in the browser.</summary>
|
||||
public Uri EndSessionUri { get; }
|
||||
|
||||
/// <summary>The registered callback the provider sends the browser to when the sign-out is finished.</summary>
|
||||
public Uri RedirectUri { get; }
|
||||
|
||||
/// <summary>The random state this sign-out sent and expects back.</summary>
|
||||
public string State { get; }
|
||||
|
||||
|
||||
|
||||
/// <summary>True only for a callback on exactly the registered address, with no fragment or user information and this sign-out's own state, so a forged or stale return cannot be taken as the provider finishing.</summary>
|
||||
public bool Matches(Uri callback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
if(!callback.IsAbsoluteUri || callback.Fragment.Length != 0 || callback.UserInfo.Length != 0
|
||||
|| !string.Equals(callback.GetLeftPart(UriPartial.Path), RedirectUri.AbsoluteUri, StringComparison.Ordinal) || callback.Query.Length < 2)
|
||||
{ return false; }
|
||||
string? state = null;
|
||||
foreach(string entry in callback.Query[1..].Split('&'))
|
||||
{
|
||||
string[] pair = entry.Split('=', 2);
|
||||
if(pair.Length != 2)
|
||||
{ return false; }
|
||||
if(string.Equals(Uri.UnescapeDataString(pair[0].Replace('+', ' ')), "state", StringComparison.Ordinal))
|
||||
{
|
||||
if(state is not null)
|
||||
{ return false; }
|
||||
state = Uri.UnescapeDataString(pair[1].Replace('+', ' '));
|
||||
}
|
||||
}
|
||||
return string.Equals(state, State, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns a fixed redacted label, so logging or displaying a sign-out can never reveal the ID token hint or state.</summary>
|
||||
public override string ToString() => "QQQ provider sign-out (redacted)";
|
||||
}
|
||||
@@ -45,6 +45,8 @@ public sealed partial class QqqSessionClient
|
||||
["state"] = state,
|
||||
["nonce"] = nonce,
|
||||
["prompt"] = grant.Prompt,
|
||||
// prompt=login alone was seen reusing a retained provider session on real Authentik; max_age=0 makes any session too old.
|
||||
["max_age"] = "0",
|
||||
["code_challenge_method"] = "S256",
|
||||
["code_challenge"] = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Provider sign-out part of the session client. Signing out of the agent must also end the identity provider's browser session,
|
||||
/// because the provider is meant to be invisible: leaving it signed in would let the next sign-in skip the credential prompt.
|
||||
/// The server describes the provider's end-session request at logout, this client checks it against the provider's own discovery,
|
||||
/// and the host opens it in the browser.
|
||||
/// </summary>
|
||||
public sealed partial class QqqSessionClient
|
||||
{
|
||||
private const int IdTokenHintLimit = 32_768;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Ends the QQQ session and, when the server offers provider sign-out for this exact callback, returns the provider's end-session
|
||||
/// request for the host to open in the browser. The request must name the issuer QQQ advertises, an end-session endpoint that is
|
||||
/// HTTPS on the issuer's origin and identical to the provider's own discovery, and the callback that was asked for; anything else
|
||||
/// is refused. A server without the capability gets the ordinary logout and null, and says so in the log. The local session is
|
||||
/// forgotten in every case, including failure.
|
||||
/// </summary>
|
||||
public async Task<QqqProviderLogout?> SignOutWithProviderAsync(Uri postLogoutRedirectUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(postLogoutRedirectUri);
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if(_sessionToken is null)
|
||||
{
|
||||
_diagnostics.Info("Sign-out requested with no held session; clearing local state only.");
|
||||
return null;
|
||||
}
|
||||
_diagnostics.Info($"Signing out of {Origin.Authority}: ending the server session and asking for the identity provider sign-out.");
|
||||
QqqAuthenticationMetadata metadata = await AuthenticationCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
QqqAuthenticationMetadata.NativeGrant? grant = metadata.Values?.NativeSessionLogin;
|
||||
string? unavailable = ProviderLogoutUnavailable(metadata, grant, postLogoutRedirectUri);
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(Origin, "/qqq/v1/logout"), HttpMethod.Post,
|
||||
unavailable is null ? JsonBody(new { nativeLogout = new { postLogoutRedirectUri = postLogoutRedirectUri.AbsoluteUri } }) : JsonBody(new { }),
|
||||
MetadataLimit, CookieHeader(), null, null, cancellationToken).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
_diagnostics.Info("Server session ended.");
|
||||
if(unavailable is not null)
|
||||
{
|
||||
_diagnostics.Info("The identity provider session was NOT ended: " + unavailable + ".");
|
||||
return null;
|
||||
}
|
||||
return await ProviderLogoutFromAsync(response, grant!, postLogoutRedirectUri, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch(Exception exception) when(ReportFailure("Provider sign-out", exception))
|
||||
{ throw; }
|
||||
finally
|
||||
{
|
||||
ClearSession();
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Says why provider sign-out cannot be requested (no capability, no listed callback, a password server), or null when it can.</summary>
|
||||
private string? ProviderLogoutUnavailable(QqqAuthenticationMetadata metadata, QqqAuthenticationMetadata.NativeGrant? grant, Uri redirect)
|
||||
{
|
||||
if(!string.Equals(metadata.Type, "OAUTH2", StringComparison.Ordinal) || grant is null)
|
||||
{ return "this server signs in with a password, so there is no identity provider session"; }
|
||||
if(_discovery?.SupportsNativeSessionLogout != true)
|
||||
{ return "the server does not advertise the nativeSessionLogout capability (UI API 1.10 or later)"; }
|
||||
QqqAuthenticationMetadata.NativeLogout? logout = metadata.Values?.NativeSessionLogout;
|
||||
if(logout is null || !string.Equals(logout.Flow, QqqAuthenticationMetadata.NativeLogout.RpInitiatedFlow, StringComparison.Ordinal))
|
||||
{ return "the server publishes no OIDC_RP_INITIATED_LOGOUT descriptor"; }
|
||||
if(logout.PostLogoutRedirectUris is null || !logout.PostLogoutRedirectUris.Contains(redirect.AbsoluteUri, StringComparer.Ordinal))
|
||||
{ return $"the server does not list {redirect.AbsoluteUri} as a post-logout callback"; }
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads and checks the provider data the server returned at logout, then builds the end-session request with a fresh state. Anything that does not match is refused without logging the hint.</summary>
|
||||
private async Task<QqqProviderLogout?> ProviderLogoutFromAsync(HttpResponseMessage response, QqqAuthenticationMetadata.NativeGrant grant, Uri redirect, CancellationToken cancellationToken)
|
||||
{
|
||||
using JsonDocument document = ParseDocument(response);
|
||||
if(!document.RootElement.TryGetProperty("nativeLogoutData", out JsonElement data) || data.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
_diagnostics.Info("The identity provider session was NOT ended: the server returned no provider sign-out data (an older session, or none was stored).");
|
||||
return null;
|
||||
}
|
||||
string? issuer = StringProperty(data, "issuer");
|
||||
string? endpoint = StringProperty(data, "endSessionEndpoint");
|
||||
string? callback = StringProperty(data, "postLogoutRedirectUri");
|
||||
string? hint = StringProperty(data, "idTokenHint");
|
||||
if(!string.Equals(issuer, grant.Issuer, StringComparison.Ordinal) || !string.Equals(callback, redirect.AbsoluteUri, StringComparison.Ordinal)
|
||||
|| string.IsNullOrEmpty(hint) || hint.Length > IdTokenHintLimit || hint.Any(character => char.IsWhiteSpace(character) || char.IsControl(character))
|
||||
|| !TryHttps(endpoint, out Uri? endSession) || !SameOrigin(endSession!, new Uri(grant.Issuer)))
|
||||
{
|
||||
_diagnostics.Info("Provider sign-out refused: the data the server returned does not match the issuer, callback or HTTPS end-session endpoint it advertised.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
Uri discovered = await ProviderEndSessionEndpointAsync(grant, cancellationToken).ConfigureAwait(false);
|
||||
if(!string.Equals(discovered.AbsoluteUri, endSession!.AbsoluteUri, StringComparison.Ordinal))
|
||||
{
|
||||
_diagnostics.Info("Provider sign-out refused: the end-session endpoint from the server differs from the one in the identity provider's own discovery.");
|
||||
throw InvalidResponse();
|
||||
}
|
||||
string state = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
var query = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["id_token_hint"] = hint,
|
||||
["post_logout_redirect_uri"] = redirect.AbsoluteUri,
|
||||
["state"] = state,
|
||||
};
|
||||
var builder = new UriBuilder(endSession)
|
||||
{
|
||||
Query = string.Join("&", query.Select(pair => Uri.EscapeDataString(pair.Key) + "=" + Uri.EscapeDataString(pair.Value))),
|
||||
};
|
||||
_diagnostics.Info($"Provider sign-out ready: the end-session page is {QqqSessionDiagnostics.Describe(builder.Uri)} (query not logged).");
|
||||
return new QqqProviderLogout(builder.Uri, redirect, state);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads a string property of a JSON object, or null when it is missing or not a string.</summary>
|
||||
private static string? StringProperty(JsonElement element, string name) => element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the identity provider's discovery document and returns its end-session endpoint, after the same issuer, HTTPS and
|
||||
/// same-origin checks the authorization endpoint gets. A provider without one cannot be signed out of from here.
|
||||
/// </summary>
|
||||
private async Task<Uri> ProviderEndSessionEndpointAsync(QqqAuthenticationMetadata.NativeGrant grant, CancellationToken cancellationToken)
|
||||
{
|
||||
_diagnostics.Info($"Fetching the identity provider's discovery document from {QqqSessionDiagnostics.Describe(new Uri(grant.DiscoveryUrl))} for its end-session endpoint.");
|
||||
using HttpResponseMessage response = await SendCoreAsync(new Uri(grant.DiscoveryUrl), HttpMethod.Get, null, MetadataLimit,
|
||||
null, null, null, cancellationToken, providerDiscovery: true).ConfigureAwait(false);
|
||||
RequireSuccess(response);
|
||||
using JsonDocument document = ParseDocument(response);
|
||||
string? providerIssuer = StringProperty(document.RootElement, "issuer");
|
||||
string? endpoint = StringProperty(document.RootElement, "end_session_endpoint");
|
||||
if(!string.Equals(providerIssuer, grant.Issuer, StringComparison.Ordinal) || !TryHttps(endpoint, out Uri? endSession) || !SameOrigin(endSession!, new Uri(grant.Issuer)))
|
||||
{
|
||||
_diagnostics.Info("Provider sign-out refused: the provider's discovery names a different issuer or no HTTPS end-session endpoint on the issuer's origin.");
|
||||
throw QqqApiDiscovery.Configuration();
|
||||
}
|
||||
return endSession!;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json;
|
||||
using Qqq.PrintAgent.Core;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
using Qqq.PrintAgent.Core.Printing;
|
||||
@@ -119,8 +120,25 @@ public sealed partial class AgentRuntime
|
||||
|
||||
|
||||
/// <summary>Runs sign-out as a connection transition, so it cannot overlap a claim or a printing job.</summary>
|
||||
internal async Task<string> SignOutAsync(CancellationToken token)
|
||||
=> await ConnectionTransitionAsync(() => SignOutCoreAsync(token), token).ConfigureAwait(false);
|
||||
internal async Task<string> SignOutAsync(CancellationToken token) => await SignOutAsync(null, token).ConfigureAwait(false);
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Runs sign-out as a connection transition. When the companion passes the application's registered callback, the identity
|
||||
/// provider's own sign-out is requested too and the reply is a <see cref="PipeSignOutResult"/> carrying it for the companion to open in
|
||||
/// the browser; without a callback (the command line) only the QQQ session ends and the reply is plain text.
|
||||
/// </summary>
|
||||
internal async Task<string> SignOutAsync(string? postLogoutRedirectUri, CancellationToken token)
|
||||
{
|
||||
Uri? redirect = null;
|
||||
if(postLogoutRedirectUri is not null && (!string.Equals(postLogoutRedirectUri, QqqBrowserCallback.RedirectUri, StringComparison.Ordinal)
|
||||
|| !Uri.TryCreate(postLogoutRedirectUri, UriKind.Absolute, out redirect)))
|
||||
{
|
||||
throw new InvalidOperationException("The sign-out callback is not this application's registered callback.");
|
||||
}
|
||||
return await ConnectionTransitionAsync(() => SignOutCoreAsync(redirect, token), token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -129,7 +147,7 @@ public sealed partial class AgentRuntime
|
||||
/// sign-in is checked against the same user. An unreadable saved session is moved aside, not deleted. The server session is ended when
|
||||
/// it can be, and the local client and connection state are always cleared, even if ending it on the server fails.
|
||||
/// </summary>
|
||||
private async Task<string> SignOutCoreAsync(CancellationToken token)
|
||||
private async Task<string> SignOutCoreAsync(Uri? postLogoutRedirect, CancellationToken token)
|
||||
{
|
||||
await _authenticationGate.WaitAsync(token).ConfigureAwait(false);
|
||||
try
|
||||
@@ -145,6 +163,7 @@ public sealed partial class AgentRuntime
|
||||
{
|
||||
store.SaveAuthentication(new AgentAuthentication(saved.Binding, null));
|
||||
}
|
||||
QqqProviderLogout? providerLogout = null;
|
||||
try
|
||||
{
|
||||
if(_sessionClient is null && saved?.Credential is not null)
|
||||
@@ -155,7 +174,10 @@ public sealed partial class AgentRuntime
|
||||
}
|
||||
if(_sessionClient is not null)
|
||||
{
|
||||
await _sessionClient.SignOutAsync(token).ConfigureAwait(false);
|
||||
if(postLogoutRedirect is null)
|
||||
{ await _sessionClient.SignOutAsync(token).ConfigureAwait(false); }
|
||||
else
|
||||
{ providerLogout = await _sessionClient.SignOutWithProviderAsync(postLogoutRedirect, token).ConfigureAwait(false); }
|
||||
}
|
||||
else
|
||||
{ WriteLog("INFO", "No session to end on the server; local state cleared."); }
|
||||
@@ -172,9 +194,11 @@ public sealed partial class AgentRuntime
|
||||
ResetConnectionState();
|
||||
WriteLog("INFO", "Signed out locally. Device pairing and print history were preserved.");
|
||||
}
|
||||
return unreadableSession
|
||||
string message = unreadableSession
|
||||
? $"Signed out locally. Unreadable session data was preserved for recovery; revoke the previous session in {AppIdentity.ServerName}. Device pairing and print history were preserved."
|
||||
: "Signed out. Device pairing and print history were preserved.";
|
||||
// The hint inside the provider request is sensitive: it goes only into the reply, never into the log.
|
||||
return postLogoutRedirect is null ? message : JsonSerializer.Serialize(new PipeSignOutResult(message, providerLogout), PrintApiClient.JsonOptions);
|
||||
}
|
||||
finally { _authenticationGate.Release(); }
|
||||
}
|
||||
|
||||
@@ -442,7 +442,7 @@ public sealed partial class AgentRuntime(string directory, Action<string> log, I
|
||||
case "sign-in":
|
||||
return await SignInAsync(request, token).ConfigureAwait(false);
|
||||
case "sign-out":
|
||||
return await SignOutAsync(token).ConfigureAwait(false);
|
||||
return await SignOutAsync(request.PostLogoutRedirectUri, token).ConfigureAwait(false);
|
||||
case "reconnect":
|
||||
return ReconnectLocal();
|
||||
case "test-print":
|
||||
|
||||
@@ -13,7 +13,7 @@ public sealed record PipeRequest(string Operation, string? ServerUrl = null, str
|
||||
string? JobId = null, string? Format = null, bool ConfirmDuplicateRisk = false, JobQuery? JobQuery = null, LogSettings? LogSettings = null,
|
||||
Qqq.PrintAgent.Core.Printing.DeviceProfile? DeviceProfile = null, PrinterProfile? PrinterProfile = null, string? TestProfile = null,
|
||||
Qqq.PrintAgent.Core.Authentication.QqqSessionCredential? SessionCredential = null, bool ClaimLegacyDevice = false, bool StartNewPairing = false,
|
||||
ServerTrust? ServerTrust = null)
|
||||
ServerTrust? ServerTrust = null, string? PostLogoutRedirectUri = null)
|
||||
{
|
||||
/// <summary>Returns a fixed label instead of the generated record text, so logging a request can never reveal a session credential or enrollment code.</summary>
|
||||
public override string ToString() => "Print agent management request (credentials redacted)";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
|
||||
namespace Qqq.PrintAgent.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// The service's reply to a companion sign-out: the plain message, and the identity provider's sign-out request when the server
|
||||
/// offers one, for the companion to open in the browser so the provider's own session ends too. It carries the provider's ID token
|
||||
/// hint, so it is never logged and prints as fixed text.
|
||||
/// </summary>
|
||||
public sealed record PipeSignOutResult(string Message, QqqProviderLogout? ProviderLogout)
|
||||
{
|
||||
/// <summary>Returns a fixed label instead of the generated record text, so logging a reply can never reveal the provider sign-out request.</summary>
|
||||
public override string ToString() => "Print agent sign-out result (provider request redacted)";
|
||||
}
|
||||
@@ -234,7 +234,11 @@ public sealed partial class MainWindow
|
||||
|
||||
|
||||
|
||||
/// <summary>Asks the service to sign out and forget its session, then drops the window's own sign-in client. Pairing and job history are not removed.</summary>
|
||||
/// <summary>
|
||||
/// Asks the service to sign out and forget its session, then drops the window's own sign-in client. The identity provider is meant
|
||||
/// to be invisible, so the service also returns the provider's own sign-out when the server offers one and this window opens it in
|
||||
/// the browser: staying signed in to the provider would let the next sign-in skip the credential prompt. Pairing and job history are not removed.
|
||||
/// </summary>
|
||||
private async void SignOut_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if(_busy)
|
||||
@@ -243,11 +247,23 @@ public sealed partial class MainWindow
|
||||
try
|
||||
{
|
||||
App.Log.Info("[Companion] Sign-out requested; asking the service to end the session.");
|
||||
string result = await _sendRequest(new PipeRequest("sign-out"));
|
||||
string result = await _sendRequest(new PipeRequest("sign-out", PostLogoutRedirectUri: QqqBrowserCallback.RedirectUri));
|
||||
_signInClient?.Dispose();
|
||||
_signInClient = null;
|
||||
App.Log.Info("[Companion] Service sign-out result: " + result);
|
||||
Feedback("Account sign-out", result, result.StartsWith("Error:", StringComparison.Ordinal) ? InfoBarSeverity.Warning : InfoBarSeverity.Informational);
|
||||
PipeSignOutResult? outcome = ReadSignOutResult(result);
|
||||
string message = outcome?.Message ?? result;
|
||||
App.Log.Info("[Companion] Service sign-out result: " + message + (outcome?.ProviderLogout is null ? "" : " The identity provider sign-out is ready; opening it in the browser."));
|
||||
InfoBarSeverity severity = message.StartsWith("Error:", StringComparison.Ordinal) ? InfoBarSeverity.Warning : InfoBarSeverity.Informational;
|
||||
if(outcome?.ProviderLogout is { } providerLogout)
|
||||
{
|
||||
Feedback("Account sign-out", message + " Finish signing out of the identity provider on the page that opened in your browser.", severity);
|
||||
bool providerEnded = await CompleteProviderSignOutAsync(providerLogout);
|
||||
Feedback("Account sign-out", providerEnded ? message + " The identity provider session ended too, so the next sign-in asks for credentials."
|
||||
: message + " The identity provider sign-out was opened in your browser but did not confirm; close that page and sign out there if it is still open.",
|
||||
providerEnded ? InfoBarSeverity.Success : InfoBarSeverity.Warning);
|
||||
}
|
||||
else
|
||||
{ Feedback("Account sign-out", message, severity); }
|
||||
}
|
||||
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or OperationCanceledException)
|
||||
{
|
||||
@@ -259,6 +275,46 @@ public sealed partial class MainWindow
|
||||
|
||||
|
||||
|
||||
/// <summary>Reads the service's sign-out reply: structured text when a provider sign-out was requested, or null for a plain message or an error text.</summary>
|
||||
private static PipeSignOutResult? ReadSignOutResult(string response)
|
||||
{
|
||||
if(response.StartsWith("Error:", StringComparison.Ordinal))
|
||||
{ return null; }
|
||||
try
|
||||
{ return JsonSerializer.Deserialize<PipeSignOutResult>(response, Qqq.PrintAgent.Core.Printing.PrintApiClient.JsonOptions); }
|
||||
catch(JsonException)
|
||||
{ return null; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Opens the identity provider's end-session page in the default browser and waits up to two minutes for the browser to return
|
||||
/// to this application's registered callback with this sign-out's own state. The inbox is opened first so a fast redirect is not lost.
|
||||
/// The request carries a sensitive token hint, so only its host is logged. Returns whether the provider confirmed.
|
||||
/// </summary>
|
||||
private static async Task<bool> CompleteProviderSignOutAsync(QqqProviderLogout logout)
|
||||
{
|
||||
try
|
||||
{
|
||||
using QqqBrowserCallback inbox = new();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromMinutes(2));
|
||||
App.Log.Info("[Companion] Opening the identity provider's sign-out page in the default browser (" + logout.EndSessionUri.Authority + ").");
|
||||
_ = Process.Start(new ProcessStartInfo(logout.EndSessionUri.AbsoluteUri) { UseShellExecute = true });
|
||||
Uri callback = await inbox.ReceiveAsync(timeout.Token);
|
||||
bool matched = logout.Matches(callback);
|
||||
App.Log.Info("[Companion] Identity provider sign-out callback " + (matched ? "matched." : "did not match this sign-out."));
|
||||
return matched;
|
||||
}
|
||||
catch(Exception exception) when(exception is OperationCanceledException or IOException or InvalidDataException or UnauthorizedAccessException or System.ComponentModel.Win32Exception)
|
||||
{
|
||||
App.Log.Warning("[Companion] Identity provider sign-out was not confirmed: " + QqqSessionDiagnostics.Describe(exception));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Runs the browser sign-in with a three minute limit and a Cancel button. The redirect address must be the one this
|
||||
/// application's installer registered, and only an address the server also lists is used, so a server cannot steer the
|
||||
|
||||
@@ -22,7 +22,7 @@ public sealed class AgentControlConcurrencyTests
|
||||
using ControlFixture fixture = new();
|
||||
fixture.OfferJob = true;
|
||||
fixture.Printer.BlockPrint = true;
|
||||
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(60)); // hang guard only: the test normally finishes in well under a second, but a loaded CI host can take seconds
|
||||
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(10));
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
Task running = runtime.RunAsync(stop.Token);
|
||||
try
|
||||
|
||||
@@ -34,7 +34,7 @@ public sealed class AgentNewPairingTests
|
||||
ChangeOldConfiguration(fixture, unreadable);
|
||||
byte[] retained = File.ReadAllBytes(path);
|
||||
fixture.NextSession = UnboundSession();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
using(AgentRuntime runtime = fixture.Runtime())
|
||||
{
|
||||
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.NextSession.ExportCredential(), StartNewPairing: true), timeout.Token);
|
||||
@@ -77,7 +77,7 @@ public sealed class AgentNewPairingTests
|
||||
string path = Path.Combine(fixture.Local.DirectoryPath, "connection.dpapi");
|
||||
byte[] retained = File.ReadAllBytes(path);
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
_ = await runtime.PollControlsOnceAsync(timeout.Token);
|
||||
Assert.False(runtime.GetSnapshot().Authentication!.Authorized);
|
||||
Assert.Equal(0, fixture.Created);
|
||||
@@ -119,7 +119,7 @@ public sealed class AgentNewPairingTests
|
||||
string authPath = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
|
||||
byte[] originalAuth = File.ReadAllBytes(authPath);
|
||||
fixture.NextSession = UnboundSession();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
using(AgentRuntime runtime = fixture.Runtime())
|
||||
{
|
||||
await Assert.ThrowsAsync<QqqSessionException>(() => runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.NextSession.ExportCredential()), timeout.Token));
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed class AgentPipeFramingTests
|
||||
public async Task Write_CancelsWhenConnectedClientDoesNotRead()
|
||||
{
|
||||
string name = "qqq-framing-" + Guid.NewGuid().ToString("N");
|
||||
using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10));
|
||||
await using NamedPipeServerStream server = new(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, 4096, 4096);
|
||||
await using NamedPipeClientStream client = new(".", name, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
Task connect = client.ConnectAsync(deadline.Token);
|
||||
@@ -60,7 +60,7 @@ public sealed class AgentPipeFramingTests
|
||||
public async Task Read_CancelsWhenConnectedClientNeverTerminatesCommand()
|
||||
{
|
||||
string name = "qqq-framing-" + Guid.NewGuid().ToString("N");
|
||||
using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(10));
|
||||
await using NamedPipeServerStream server = new(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
|
||||
await using NamedPipeClientStream client = new(".", name, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
Task connect = client.ConnectAsync(deadline.Token);
|
||||
|
||||
@@ -31,7 +31,7 @@ public sealed class AgentPipeSecurityTests
|
||||
security.AddAccessRule(new PipeAccessRule(identity.User!, PipeAccessRights.ReadWrite, AccessControlType.Allow));
|
||||
await using NamedPipeServerStream server = NamedPipeServerStreamAcl.Create(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous | PipeOptions.FirstPipeInstance, 4096, 4096, security);
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
for(int connection = 0; connection < 2; connection++)
|
||||
{
|
||||
Task accepted = server.WaitForConnectionAsync(timeout.Token);
|
||||
@@ -58,7 +58,7 @@ public sealed class AgentPipeSecurityTests
|
||||
{
|
||||
string name = "QqqPrintAgent-untrusted-test-" + Guid.NewGuid().ToString("N");
|
||||
await using NamedPipeServerStream fake = new(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.FirstPipeInstance);
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
Task connected = fake.WaitForConnectionAsync(timeout.Token);
|
||||
Task<string> request = AgentPipeClient.SendAsync(new PipeRequest(operation, "https://print.example.com", "synthetic-enrollment-never-send"), name);
|
||||
await connected;
|
||||
|
||||
@@ -39,16 +39,16 @@ public sealed class AgentRealtimeBandwidthTests
|
||||
{
|
||||
// Both real runtime lanes must reach their independent body streams before
|
||||
// timing starts, so serialized admission cannot imitate an aggregate cap.
|
||||
await Task.WhenAll(first.Entered.Task, second.Entered.Task).WaitAsync(TimeSpan.FromSeconds(60));
|
||||
await Task.WhenAll(first.Entered.Task, second.Entered.Task).WaitAsync(TimeSpan.FromSeconds(5));
|
||||
long started = Stopwatch.GetTimestamp();
|
||||
release.TrySetResult();
|
||||
await fixture.WaitAsync(() => fixture.Printer.Prints == 2).WaitAsync(TimeSpan.FromSeconds(60));
|
||||
await fixture.WaitAsync(() => fixture.Printer.Prints == 2).WaitAsync(TimeSpan.FromSeconds(10));
|
||||
TimeSpan elapsed = Stopwatch.GetElapsedTime(started);
|
||||
|
||||
// Together these bodies need four seconds at the shared rate; independent
|
||||
// per-file throttles would permit both to finish after about two seconds.
|
||||
Assert.True(elapsed >= TimeSpan.FromSeconds(3.5), "Concurrent documents exceeded the configured aggregate download rate.");
|
||||
await fixture.WaitAsync(() => fixture.State("first-budget") is "SPOOLED" && fixture.State("second-budget") is "SPOOLED").WaitAsync(TimeSpan.FromSeconds(60));
|
||||
await fixture.WaitAsync(() => fixture.State("first-budget") is "SPOOLED" && fixture.State("second-budget") is "SPOOLED").WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(0, fixture.ForbiddenPolls);
|
||||
}
|
||||
finally { release.TrySetResult(); }
|
||||
|
||||
@@ -34,7 +34,7 @@ public sealed class AgentRealtimeGenerationTests
|
||||
{
|
||||
using var fixture = new SessionFixture();
|
||||
fixture.SaveSession();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var replacementEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class AgentRealtimeOrderingTests
|
||||
using var slow = new GatedDocument(bytes);
|
||||
fixture.AddJob("slow", () => slow, length: bytes.Length);
|
||||
await connection.SendAsync("JOB_AVAILABLE", new { });
|
||||
await slow.Entered.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
await slow.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
var observed = new TaskCompletionSource<(bool Connected, string? State, int Prints)>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
fixture.BeforeAcknowledgement = async message =>
|
||||
{
|
||||
@@ -48,7 +48,7 @@ public sealed class AgentRealtimeOrderingTests
|
||||
try
|
||||
{
|
||||
await connection.SendAsync("DESIRED_STATE", new Qqq.PrintAgent.Core.Printing.DeviceDesiredState(2, false, "CONNECTED"));
|
||||
(bool connected, string? state, int prints) = await observed.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
(bool connected, string? state, int prints) = await observed.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.False(connected);
|
||||
Assert.Equal("RECEIVED", state);
|
||||
Assert.Equal(0, prints);
|
||||
@@ -98,7 +98,7 @@ public sealed class AgentRealtimeOrderingTests
|
||||
using var slow = new GatedDocument(bytes);
|
||||
fixture.AddJob("slow", () => slow, length: bytes.Length);
|
||||
await connection.SendAsync("JOB_AVAILABLE", new { });
|
||||
await slow.Entered.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
await slow.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await connection.SendAsync("SETTINGS", fixture.Settings with { Revision = 2, NativeSubmissionTimeoutMillis = 2345, MaxPdfBytes = rejectSize ? 1 : fixture.Settings.MaxPdfBytes });
|
||||
await connection.SendAsync("DESIRED_STATE", new Qqq.PrintAgent.Core.Printing.DeviceDesiredState(2, true, "CONNECTED"));
|
||||
await fixture.WaitAsync(() => fixture.Runtime.GetSnapshot().Control!.DesiredState.DispatchPaused);
|
||||
@@ -123,7 +123,7 @@ public sealed class AgentRealtimeOrderingTests
|
||||
fixture.AddJob("slow", () => slow, length: bytes.Length);
|
||||
fixture.AddJob("fast", printerId: "other");
|
||||
await connection.SendAsync("JOB_AVAILABLE", new { });
|
||||
await slow.Entered.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
await slow.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
try
|
||||
{
|
||||
Assert.Empty(fixture.Printer.Order);
|
||||
@@ -156,8 +156,8 @@ public sealed class AgentRealtimeOrderingTests
|
||||
fixture.AddJob("slow", () => slow, length: slowBytes.Length);
|
||||
fixture.AddJob("fast", () => { fastStarted.TrySetResult(); return new MemoryStream(fastBytes); }, samePrinter ? "printer" : "other", fastBytes.Length);
|
||||
await connection.SendAsync("JOB_AVAILABLE", new { });
|
||||
await slow.Entered.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
await fastStarted.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
await slow.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await fastStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
try
|
||||
{
|
||||
if(samePrinter)
|
||||
|
||||
@@ -24,7 +24,7 @@ public sealed class AgentRealtimeRecoveryTests
|
||||
byte[] unreadable = [1, 3, 5];
|
||||
File.WriteAllBytes(path, unreadable);
|
||||
string name = "qqq-recovery-" + Guid.NewGuid().ToString("N");
|
||||
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
using var runtime = new AgentRuntime(fixture.DirectoryPath, _ => { }, fixture.Printer, pipeName: name,
|
||||
pipeFactory: () => new NamedPipeServerStream(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly));
|
||||
Task running = runtime.RunAsync(stop.Token);
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class AgentRealtimeTests
|
||||
using var stalled = new StalledDocument();
|
||||
fixture.AddJob("large", () => stalled);
|
||||
await connection.SendAsync("JOB_AVAILABLE", new { });
|
||||
await stalled.Entered.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
await stalled.Entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await connection.SendAsync("DESIRED_STATE", new DeviceDesiredState(2, true, "CONNECTED"));
|
||||
await fixture.WaitAsync(() => fixture.Runtime.GetSnapshot().Control!.DesiredState.DispatchPaused);
|
||||
Assert.Equal(0, fixture.Printer.Prints);
|
||||
@@ -83,7 +83,7 @@ public sealed class AgentRealtimeTests
|
||||
RealtimeRuntimeFixture.Connection first = await fixture.ConnectAsync(loseSpoolAck: true);
|
||||
fixture.AddJob("first");
|
||||
await first.SendAsync("JOB_AVAILABLE", new { });
|
||||
await first.LostSpoolAck.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
await first.LostSpoolAck.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
fixture.AddJob("buffered");
|
||||
_ = await fixture.ConnectAsync();
|
||||
await fixture.WaitAsync(() => string.Equals(fixture.State("buffered"), "SPOOLED", StringComparison.Ordinal));
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class AgentRealtimeTransitionTests
|
||||
await using var fixture = new RealtimeRuntimeFixture(settings: new RealtimeSettings(Revision: 5));
|
||||
_ = await fixture.ConnectAsync();
|
||||
fixture.Settings = fixture.Settings with { Revision = 0 };
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
_ = await fixture.Runtime.EnrollWithGatesAsync(new PipeRequest("enroll", fixture.Peer.Origin.AbsoluteUri, "synthetic-code"), timeout.Token);
|
||||
_ = await fixture.ConnectAsync();
|
||||
Assert.Equal(0, fixture.LastHelloSettingsRevision);
|
||||
|
||||
@@ -44,11 +44,11 @@ public sealed class AgentRuntimeBacklogTests
|
||||
{
|
||||
// Synthetic responses otherwise let RunAsync drain synchronously before this
|
||||
// assertion starts. Exclude setup/control readiness from the drain budget.
|
||||
await state.FirstClaim.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
stop.CancelAfter(TimeSpan.FromSeconds(90));
|
||||
await state.FirstClaim.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
stop.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
state.ReleaseClaims.SetResult();
|
||||
// Four claims must finish before the configured 20-second idle interval.
|
||||
await state.Empty.Task.WaitAsync(TimeSpan.FromSeconds(60), stop.Token);
|
||||
await state.Empty.Task.WaitAsync(TimeSpan.FromSeconds(10), stop.Token);
|
||||
Assert.Equal(3, printer.Printed);
|
||||
await Task.Delay(250, stop.Token);
|
||||
Assert.Equal(4, Volatile.Read(ref state.Claims));
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class AgentRuntimeHeartbeatTests
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-heartbeat-" + Guid.NewGuid().ToString("N"));
|
||||
string pipeName = "QqqHeartbeat-test-" + Guid.NewGuid().ToString("N");
|
||||
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(10));
|
||||
BlockingPrinter printer = new();
|
||||
int heartbeats = 0;
|
||||
Task? running = null;
|
||||
|
||||
@@ -74,7 +74,7 @@ public sealed class AgentRuntimePipeResponseTests
|
||||
public async Task ResponseWait_RespectsDeadline_WhenClientNeverReads()
|
||||
{
|
||||
string name = "QqqPrintAgent-test-response-deadline-" + Guid.NewGuid().ToString("N");
|
||||
using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource deadline = new(TimeSpan.FromSeconds(5));
|
||||
await using NamedPipeServerStream server = new(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, 4096, 4096);
|
||||
await using NamedPipeClientStream client = new(".", name, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
@@ -152,7 +152,7 @@ public sealed class AgentRuntimePipeResponseTests
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "qqq-pipe-response-" + Guid.NewGuid().ToString("N"));
|
||||
private readonly AgentRuntime _runtime;
|
||||
public string PipeName { get; } = "QqqPrintAgent-test-response-" + Guid.NewGuid().ToString("N");
|
||||
public CancellationTokenSource Stop { get; } = new(TimeSpan.FromSeconds(60));
|
||||
public CancellationTokenSource Stop { get; } = new(TimeSpan.FromSeconds(15));
|
||||
public Task Running { get; }
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class AgentRuntimeQueuedJobsTests
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-queued-" + Guid.NewGuid().ToString("N"));
|
||||
string pipeName = "QqqQueued-test-" + Guid.NewGuid().ToString("N");
|
||||
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(10));
|
||||
ServerState server = new();
|
||||
NoPrintBackend printers = new();
|
||||
AgentRuntime? runtime = null;
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class AgentRuntimeTests
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-runtime-" + Guid.NewGuid().ToString("N"));
|
||||
string pipeName = "QqqPrintAgent-test-" + Guid.NewGuid().ToString("N");
|
||||
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource stop = new(TimeSpan.FromSeconds(20));
|
||||
TaskCompletionSource failed = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
TaskCompletionSource recovered = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
int requests = 0;
|
||||
|
||||
@@ -64,7 +64,7 @@ public sealed class AgentSessionIntegrationTests
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
QqqSessionCredential invalid = new(new Uri("https://synthetic.invalid/not-an-origin"), "synthetic-cookie");
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: invalid), CancellationToken.None));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential()), timeout.Token);
|
||||
Assert.True(runtime.GetSnapshot().Authentication!.Authorized);
|
||||
}
|
||||
@@ -81,7 +81,7 @@ public sealed class AgentSessionIntegrationTests
|
||||
using SessionFixture fixture = new();
|
||||
fixture.SaveSession();
|
||||
fixture.Session.DelayUnauthorizedPoll = true;
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
Task<int> oldPoll = runtime.PollControlsOnceAsync(timeout.Token);
|
||||
await fixture.Session.PollEntered.Task.WaitAsync(timeout.Token);
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class AgentSessionRecoveryTests
|
||||
byte[] corrupt = ProtectedData.Protect(Encoding.UTF8.GetBytes(json), null, DataProtectionScope.CurrentUser);
|
||||
File.WriteAllBytes(Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi"), corrupt);
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
using(AgentStore unreadable = new(fixture.Local.DirectoryPath))
|
||||
{
|
||||
Assert.Throws<InvalidDataException>(() => unreadable.LoadAuthentication());
|
||||
@@ -60,7 +60,7 @@ public sealed class AgentSessionRecoveryTests
|
||||
byte[] corrupt = [1, 2, 3, 4];
|
||||
File.WriteAllBytes(Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi"), corrupt);
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
fixture.Session.DenyBinding = true;
|
||||
await Assert.ThrowsAsync<Qqq.PrintAgent.Core.Authentication.QqqSessionException>(() => runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential()), timeout.Token));
|
||||
Assert.Equal(corrupt, File.ReadAllBytes(Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi")));
|
||||
@@ -96,7 +96,7 @@ public sealed class AgentSessionRecoveryTests
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
Assert.False(runtime.GetSnapshot().Enrolled);
|
||||
Assert.Equal(corrupt, File.ReadAllBytes(path));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential()), timeout.Token);
|
||||
Assert.False(runtime.GetSnapshot().Authentication!.Authorized);
|
||||
Assert.Equal(corrupt, File.ReadAllBytes(path));
|
||||
@@ -128,7 +128,7 @@ public sealed class AgentSessionRecoveryTests
|
||||
string path = Path.Combine(fixture.Local.DirectoryPath, "qqq-session.dpapi");
|
||||
File.WriteAllBytes(path, corrupt);
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("revoke the previous session in QQQ", await runtime.SignOutAsync(timeout.Token), StringComparison.Ordinal);
|
||||
Assert.False(File.Exists(path));
|
||||
Assert.Equal(corrupt, File.ReadAllBytes(Assert.Single(Directory.GetFiles(fixture.Local.DirectoryPath, "qqq-session.recovery-*.dpapi"))));
|
||||
|
||||
@@ -53,7 +53,7 @@ public sealed class AgentTransportTests
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "qqq-transport-pipe-" + Guid.NewGuid().ToString("N"));
|
||||
string name = "qqq-transport-" + Guid.NewGuid().ToString("N");
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var runtime = new AgentRuntime(directory, _ => { }, new ForbiddenPrinters(), pipeName: name,
|
||||
pipeFactory: () => new NamedPipeServerStream(name, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly));
|
||||
Task run = runtime.RunAsync(timeout.Token);
|
||||
|
||||
@@ -60,7 +60,7 @@ public sealed class BrowserCallbackTests
|
||||
{
|
||||
string name = "qqq-callback-test-" + Guid.NewGuid().ToString("N");
|
||||
using QqqBrowserCallback inbox = new(name);
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
Task<Uri> received = inbox.ReceiveAsync(timeout.Token);
|
||||
string callback = QqqBrowserCallback.RedirectUri + "?code=synthetic&state=synthetic";
|
||||
await QqqBrowserCallback.ForwardAsync(callback, name, Environment.ProcessPath!, timeout.Token);
|
||||
@@ -75,7 +75,7 @@ public sealed class BrowserCallbackTests
|
||||
{
|
||||
string name = "qqq-callback-test-" + Guid.NewGuid().ToString("N");
|
||||
using QqqBrowserCallback inbox = new(name);
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
Task<Uri> received = inbox.ReceiveAsync(timeout.Token);
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => QqqBrowserCallback.ForwardAsync(
|
||||
QqqBrowserCallback.RedirectUri + "?code=synthetic&state=synthetic", name, "C:\\not-the-installed-companion.exe", timeout.Token));
|
||||
|
||||
@@ -47,7 +47,7 @@ public sealed class InstallerPreflightTests
|
||||
await process.StandardInput.WriteAsync(script);
|
||||
process.StandardInput.Close();
|
||||
string error = await process.StandardError.ReadToEndAsync();
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
Assert.True(process.ExitCode == 0, error);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ public sealed class PrinterWorkerIsolationTests
|
||||
},
|
||||
};
|
||||
PrintJob job = new("synthetic", "fixture", "Never printed", "PDF", new string('0', 64), 1, new PrintOptions(), "", "RECEIVED");
|
||||
using var watchdog = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
using var watchdog = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await Assert.ThrowsAsync<TimeoutException>(() => backend.PrintFileAsync(job, Path.Combine(Path.GetTempPath(), "unused-synthetic.document"), TimeSpan.FromSeconds(1), watchdog.Token));
|
||||
Assert.True(ProcessExited(childId));
|
||||
}
|
||||
@@ -101,7 +101,7 @@ public sealed class PrinterWorkerIsolationTests
|
||||
ProcessStartInfo start = new(FixtureExecutable) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true };
|
||||
start.ArgumentList.Add("--launch-parent");
|
||||
using Process parent = Process.Start(start) ?? throw new InvalidOperationException("Parent fixture launch failed.");
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(60));
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(15));
|
||||
try
|
||||
{
|
||||
string? line = await parent.StandardOutput.ReadLineAsync(timeout.Token);
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Qqq.PrintAgent.Core.Authentication;
|
||||
using Qqq.PrintAgent.Core.Printing;
|
||||
using Qqq.PrintAgent.Windows;
|
||||
|
||||
namespace Qqq.PrintAgent.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Signing out must also end the identity provider's browser session, because the provider is invisible to users and the next
|
||||
/// sign-in must always ask for credentials. These tests script the QQQ server and the provider's discovery document to check that
|
||||
/// every browser sign-in forces re-authentication, that sign-out returns a provider end-session request only when everything the server
|
||||
/// said matches the provider's own discovery, and that the service hands that request to the companion without logging it.
|
||||
/// </summary>
|
||||
public sealed class ProviderSignOutTests
|
||||
{
|
||||
private static readonly Uri Origin = new("https://printing.example.invalid");
|
||||
private static readonly Uri Redirect = new(QqqBrowserCallback.RedirectUri);
|
||||
private const string Issuer = "https://id.example.invalid/application/";
|
||||
private const string EndSession = "https://id.example.invalid/application/end-session/";
|
||||
private const string Features = "\"version\",\"metaData\",\"processes\",\"processCancel\",\"extensions\",\"widgets\",\"nativeSessionLogin\"";
|
||||
private const string Extension = "\"extensions\":[{\"name\":\"printing\",\"version\":\"1.0.0\",\"pathPrefix\":\"/qbit/printing\"}]";
|
||||
private const string Hint = "header.payload.signature";
|
||||
|
||||
|
||||
|
||||
/// <summary>Builds the server's discovery document, with or without the provider sign-out capability.</summary>
|
||||
private static string Discovery(bool logout) => "{\"apiVersion\":\"" + (logout ? "1.10.0" : "1.9.0") + "\",\"features\":[" + Features + (logout ? ",\"nativeSessionLogout\"" : "") + "],\"clientChannels\":[\"api\"]," + Extension + "}";
|
||||
|
||||
|
||||
|
||||
/// <summary>Builds the OAuth2 authentication description with the native sign-in grant and, optionally, the provider sign-out descriptor for the given callbacks.</summary>
|
||||
private static string OAuth(string? logoutCallbacks) => "{\"name\":\"p\",\"type\":\"OAUTH2\",\"values\":{\"clientId\":\"c\",\"scopes\":\"openid profile\",\"nativeSessionLogin\":{\"flow\":\"AUTHORIZATION_CODE_PKCE_SESSION\",\"codeChallengeMethod\":\"S256\",\"prompt\":\"login\",\"issuer\":\""
|
||||
+ Issuer + "\",\"discoveryUrl\":\"" + Issuer + ".well-known/openid-configuration\",\"redirectUris\":[\"" + QqqBrowserCallback.RedirectUri + "\"]}"
|
||||
+ (logoutCallbacks is null ? "" : ",\"nativeSessionLogout\":{\"flow\":\"OIDC_RP_INITIATED_LOGOUT\",\"postLogoutRedirectUris\":[" + logoutCallbacks + "]}") + "}}";
|
||||
|
||||
|
||||
|
||||
/// <summary>Builds the provider's own discovery document.</summary>
|
||||
private static string Provider() => "{\"issuer\":\"" + Issuer + "\",\"authorization_endpoint\":\"https://id.example.invalid/authorize\",\"end_session_endpoint\":\"" + EndSession + "\",\"code_challenge_methods_supported\":[\"S256\"]}";
|
||||
|
||||
|
||||
|
||||
/// <summary>Builds the logout reply carrying the provider's end-session data, with any field replaced to test a mismatch.</summary>
|
||||
private static string LogoutData(string? issuer = null, string? endpoint = null, string? callback = null, string? hint = null)
|
||||
=> "{\"nativeLogoutData\":{\"issuer\":\"" + (issuer ?? Issuer) + "\",\"endSessionEndpoint\":\"" + (endpoint ?? EndSession) + "\",\"postLogoutRedirectUri\":\""
|
||||
+ (callback ?? QqqBrowserCallback.RedirectUri) + "\",\"idTokenHint\":\"" + (hint ?? Hint) + "\"}}";
|
||||
|
||||
|
||||
|
||||
/// <summary>Signs in through the scripted browser flow so the client holds a session, and returns it with the server that recorded the conversation.</summary>
|
||||
private static async Task<(QqqSessionClient Client, Server Server, QqqBrowserLogin Attempt)> SignedInAsync(bool logout, string? callbacks, params string[] afterLogin)
|
||||
{
|
||||
Server server = new();
|
||||
server.Add(Discovery(logout));
|
||||
server.Add(OAuth(callbacks));
|
||||
server.Add(Provider());
|
||||
server.Add("{}", "sessionUUID=opaque-session; Path=/; HttpOnly; Secure");
|
||||
foreach(string reply in afterLogin)
|
||||
{ server.Add(reply); }
|
||||
QqqSessionClient client = new(Origin, server);
|
||||
QqqBrowserLogin attempt = await client.BeginBrowserLoginAsync(Redirect);
|
||||
string state = Query(attempt.AuthorizationUri)["state"];
|
||||
await client.CompleteBrowserLoginAsync(attempt, new Uri(Redirect.AbsoluteUri + "?code=one-time-code&state=" + Uri.EscapeDataString(state)));
|
||||
Assert.True(client.IsAuthenticated);
|
||||
return (client, server, attempt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Every browser sign-in asks the provider to re-authenticate: prompt=login and max_age=0, because prompt=login alone was seen reusing a retained provider session.</summary>
|
||||
[Fact]
|
||||
public async Task BrowserSignInForcesFreshAuthentication()
|
||||
{
|
||||
(QqqSessionClient client, Server _, QqqBrowserLogin attempt) = await SignedInAsync(logout: true, callbacks: null);
|
||||
using(client)
|
||||
{
|
||||
Dictionary<string, string> query = Query(attempt.AuthorizationUri);
|
||||
Assert.Equal("login", query["prompt"]);
|
||||
Assert.Equal("0", query["max_age"]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// With a server that offers provider sign-out, the logout request names only the registered callback and carries only the session
|
||||
/// cookie; the returned end-session request is built for the provider's own endpoint with the hint, callback and a fresh state; the
|
||||
/// local session is gone; and only a return with that state counts as the provider finishing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SignOutReturnsTheProviderEndSessionRequestAndForgetsTheSession()
|
||||
{
|
||||
(QqqSessionClient client, Server server, QqqBrowserLogin _) = await SignedInAsync(true, "\"" + QqqBrowserCallback.RedirectUri + "\"", LogoutData(), Provider());
|
||||
using(client)
|
||||
{
|
||||
QqqProviderLogout? logout = await client.SignOutWithProviderAsync(Redirect);
|
||||
Assert.False(client.IsAuthenticated);
|
||||
Assert.NotNull(logout);
|
||||
Request request = server.Requests[4];
|
||||
Assert.Equal("/qqq/v1/logout", request.Uri.AbsolutePath);
|
||||
Assert.Null(request.Authorization);
|
||||
Assert.Contains("sessionUUID=opaque-session", request.Cookie, StringComparison.Ordinal);
|
||||
using var body = JsonDocument.Parse(request.Body!);
|
||||
Assert.Equal(QqqBrowserCallback.RedirectUri, body.RootElement.GetProperty("nativeLogout").GetProperty("postLogoutRedirectUri").GetString());
|
||||
Assert.Single(body.RootElement.EnumerateObject());
|
||||
Assert.Equal(EndSession, logout.EndSessionUri.GetLeftPart(UriPartial.Path));
|
||||
Dictionary<string, string> query = Query(logout.EndSessionUri);
|
||||
Assert.Equal(Hint, query["id_token_hint"]);
|
||||
Assert.Equal(QqqBrowserCallback.RedirectUri, query["post_logout_redirect_uri"]);
|
||||
Assert.Equal(43, query["state"].Length);
|
||||
Assert.Equal("QQQ provider sign-out (redacted)", logout.ToString());
|
||||
Assert.True(logout.Matches(new Uri(Redirect.AbsoluteUri + "?state=" + Uri.EscapeDataString(logout.State))));
|
||||
Assert.False(logout.Matches(new Uri(Redirect.AbsoluteUri + "?state=other")));
|
||||
Assert.False(logout.Matches(new Uri(Redirect.AbsoluteUri + "?code=x")));
|
||||
Assert.False(logout.Matches(new Uri("io.qrun.other:/oauth/callback?state=" + Uri.EscapeDataString(logout.State))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A server without the capability (or without this callback listed) still gets an ordinary logout that ends the QQQ session, and nothing is returned to open.</summary>
|
||||
[Theory]
|
||||
[InlineData(false, "\"io.qrun.qqq-print-agent:/oauth/callback\"")]
|
||||
[InlineData(true, null)]
|
||||
[InlineData(true, "\"io.qrun.other:/oauth/callback\"")]
|
||||
public async Task ServerWithoutProviderSignOutGetsOrdinaryLogout(bool capability, string? callbacks)
|
||||
{
|
||||
(QqqSessionClient client, Server server, QqqBrowserLogin _) = await SignedInAsync(capability, callbacks, "{}");
|
||||
using(client)
|
||||
{
|
||||
Assert.Null(await client.SignOutWithProviderAsync(Redirect));
|
||||
Assert.False(client.IsAuthenticated);
|
||||
Assert.Equal("{}", server.Requests[4].Body);
|
||||
Assert.Equal(5, server.Requests.Count);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Provider data that does not match what the server advertised, or what the provider's own discovery publishes, is refused with no
|
||||
/// request returned, and the local session is still forgotten.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("issuer")]
|
||||
[InlineData("callback")]
|
||||
[InlineData("hint-space")]
|
||||
[InlineData("hint-empty")]
|
||||
[InlineData("endpoint-http")]
|
||||
[InlineData("endpoint-other-origin")]
|
||||
[InlineData("endpoint-differs-from-discovery")]
|
||||
public async Task MismatchedProviderDataIsRefusedAndSessionStillCleared(string flaw)
|
||||
{
|
||||
string data = flaw switch
|
||||
{
|
||||
"issuer" => LogoutData(issuer: "https://evil.example.invalid/application/"),
|
||||
"callback" => LogoutData(callback: "io.qrun.other:/oauth/callback"),
|
||||
"hint-space" => LogoutData(hint: "a b"),
|
||||
"hint-empty" => LogoutData(hint: ""),
|
||||
"endpoint-http" => LogoutData(endpoint: "http://id.example.invalid/application/end-session/"),
|
||||
"endpoint-other-origin" => LogoutData(endpoint: "https://evil.example.invalid/end-session/"),
|
||||
_ => LogoutData(endpoint: "https://id.example.invalid/application/other-end-session/"),
|
||||
};
|
||||
(QqqSessionClient client, Server _, QqqBrowserLogin _) = await SignedInAsync(true, "\"" + QqqBrowserCallback.RedirectUri + "\"", data, Provider());
|
||||
using(client)
|
||||
{
|
||||
_ = await Assert.ThrowsAsync<QqqSessionException>(() => client.SignOutWithProviderAsync(Redirect));
|
||||
Assert.False(client.IsAuthenticated);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A server that returns no provider data (an older session) ends the QQQ session and returns nothing to open.</summary>
|
||||
[Fact]
|
||||
public async Task MissingProviderDataReturnsNothingToOpen()
|
||||
{
|
||||
(QqqSessionClient client, Server _, QqqBrowserLogin _) = await SignedInAsync(true, "\"" + QqqBrowserCallback.RedirectUri + "\"", "{}");
|
||||
using(client)
|
||||
{
|
||||
Assert.Null(await client.SignOutWithProviderAsync(Redirect));
|
||||
Assert.False(client.IsAuthenticated);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The companion's sign-out reaches the service with the registered callback, and the reply carries the provider request for the browser; the command line sends no callback and gets plain text.</summary>
|
||||
[Fact]
|
||||
public async Task ServiceReturnsTheProviderRequestToTheCompanionOnly()
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
QqqProviderLogout logout = new(new Uri(EndSession + "?id_token_hint=" + Hint + "&state=s"), Redirect, "s");
|
||||
fixture.Session.ProviderLogout = logout;
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential(), ClaimLegacyDevice: true), CancellationToken.None);
|
||||
string reply = await runtime.SignOutAsync(QqqBrowserCallback.RedirectUri, CancellationToken.None);
|
||||
PipeSignOutResult result = JsonSerializer.Deserialize<PipeSignOutResult>(reply, PrintApiClient.JsonOptions)!;
|
||||
Assert.Equal(Redirect, fixture.Session.RequestedLogoutRedirect);
|
||||
Assert.Equal(logout.EndSessionUri, result.ProviderLogout!.EndSessionUri);
|
||||
Assert.Equal("s", result.ProviderLogout.State);
|
||||
Assert.StartsWith("Signed out.", result.Message, StringComparison.Ordinal);
|
||||
|
||||
_ = await runtime.SignInAsync(new PipeRequest("sign-in", SessionCredential: fixture.Session.ExportCredential(), ClaimLegacyDevice: true), CancellationToken.None);
|
||||
string plain = await runtime.SignOutAsync(CancellationToken.None);
|
||||
Assert.StartsWith("Signed out.", plain, StringComparison.Ordinal);
|
||||
Assert.Equal(Redirect, fixture.Session.RequestedLogoutRedirect);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>The service accepts only this application's registered callback, so a local client cannot steer the provider's return elsewhere.</summary>
|
||||
[Fact]
|
||||
public async Task ServiceRefusesAnyOtherCallback()
|
||||
{
|
||||
using SessionFixture fixture = new();
|
||||
using AgentRuntime runtime = fixture.Runtime();
|
||||
_ = await Assert.ThrowsAsync<InvalidOperationException>(() => runtime.SignOutAsync("https://evil.example.invalid/cb", CancellationToken.None));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Splits a URL's query string into decoded name and value pairs.</summary>
|
||||
private static Dictionary<string, string> Query(Uri uri) => uri.Query[1..].Split('&').Select(part => part.Split('=', 2))
|
||||
.ToDictionary(part => Uri.UnescapeDataString(part[0]), part => Uri.UnescapeDataString(part[1]), StringComparer.Ordinal);
|
||||
|
||||
|
||||
|
||||
/// <summary>What the scripted server saw for one request.</summary>
|
||||
private sealed record Request(Uri Uri, string? Body, string? Authorization, string? Cookie);
|
||||
|
||||
|
||||
|
||||
/// <summary>Scripted server: records each request and answers in the order replies were queued, so a test fixes the whole conversation.</summary>
|
||||
private sealed class Server : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpResponseMessage> _replies = new();
|
||||
internal List<Request> Requests { get; } = [];
|
||||
|
||||
|
||||
|
||||
/// <summary>Queues a JSON reply, optionally with a Set-Cookie header.</summary>
|
||||
internal void Add(string body, string? cookie = null)
|
||||
{
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
|
||||
if(cookie is not null)
|
||||
{ response.Headers.Add("Set-Cookie", cookie); }
|
||||
_replies.Enqueue(response);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Records the request and returns the next queued reply.</summary>
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
string? body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
Requests.Add(new Request(request.RequestUri!, body, request.Headers.Authorization?.ToString(), request.Headers.TryGetValues("Cookie", out IEnumerable<string>? cookie) ? string.Join("; ", cookie) : null));
|
||||
return _replies.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ public sealed class QqqBrowserCallbackInboxTests
|
||||
{
|
||||
string name = "QqqPrintAgent.Auth.Test." + Guid.NewGuid().ToString("N");
|
||||
using QqqBrowserCallback inbox = new(name);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
||||
Task<Uri> receiving = inbox.ReceiveAsync(timeout.Token);
|
||||
var steps = new List<string>();
|
||||
await QqqBrowserCallback.ForwardAsync(QqqBrowserCallback.RedirectUri + "?code=test-code&state=test-state", name, Environment.ProcessPath!, timeout.Token, steps.Add);
|
||||
|
||||
@@ -116,7 +116,7 @@ internal sealed class RealtimeRuntimeFixture : IAsyncDisposable
|
||||
{
|
||||
WebSocket socket;
|
||||
try
|
||||
{ socket = await Peer.AcceptAsync(_stopping.Token).WaitAsync(TimeSpan.FromSeconds(60)); }
|
||||
{ socket = await Peer.AcceptAsync(_stopping.Token).WaitAsync(TimeSpan.FromSeconds(5)); }
|
||||
catch(TimeoutException exception) { throw new InvalidOperationException(Diagnostic("WebSocket handshake"), exception); }
|
||||
var connection = new Connection(socket, this, loseSpoolAck, _stopping.Token);
|
||||
_connections.Add(connection);
|
||||
|
||||
@@ -122,6 +122,24 @@ internal sealed class SessionFixture : IDisposable
|
||||
|
||||
|
||||
|
||||
/// <summary>The scripted provider sign-out the fake server returns at logout; null means the server offers none.</summary>
|
||||
internal QqqProviderLogout? ProviderLogout { get; set; }
|
||||
|
||||
/// <summary>The callback the service asked the provider to return to, or null when only the QQQ session was ended.</summary>
|
||||
internal Uri? RequestedLogoutRedirect { get; private set; }
|
||||
|
||||
|
||||
|
||||
/// <summary>Marks the session signed out, records which callback was asked for and returns the scripted provider sign-out.</summary>
|
||||
public Task<QqqProviderLogout?> SignOutWithProviderAsync(Uri postLogoutRedirectUri, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IsAuthenticated = false;
|
||||
RequestedLogoutRedirect = postLogoutRedirectUri;
|
||||
return Task.FromResult(ProviderLogout);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>Returns the scripted principal, tenant and device binding.</summary>
|
||||
public Task<QqqSessionBinding> GetSessionIdentityAsync(CancellationToken cancellationToken = default) => Task.FromResult(Binding);
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ public sealed class ApplicationTrafficTransportTests
|
||||
Assert.Equal(handler.Downloaded, before.DownloadedBytes);
|
||||
Assert.True(before.UploadedBytes > 0);
|
||||
using var api = PrintApiClient.ForSession(session);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
string staging = Path.Combine(Path.GetTempPath(), "qqq-traffic-" + Guid.NewGuid().ToString("N"));
|
||||
var job = new PrintJob("job", "printer", "Synthetic", "PDF", Convert.ToHexString(SHA256.HashData(document)), document.Length, new PrintOptions(), "", "CLAIMED");
|
||||
Task<DownloadedDocument> download = api.DownloadFileAsync(job, staging, new RealtimeSettings(), timeout.Token);
|
||||
@@ -88,7 +88,7 @@ public sealed class ApplicationTrafficTransportTests
|
||||
public async Task WebSocketCountsSentAndFragmentedUtf8PayloadsWithoutCountingQueuedFramesOrHandshake()
|
||||
{
|
||||
using var peer = new LoopbackWebSocketPeer();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
using var client = new ClientWebSocket();
|
||||
client.Options.AddSubProtocol("qqq-printing.v1");
|
||||
Task<WebSocket> accepting = peer.AcceptAsync(timeout.Token);
|
||||
|
||||
@@ -661,9 +661,9 @@ public sealed class QqqSessionAuthenticationTests
|
||||
await client.SignInWithPasswordAsync("user", "password");
|
||||
await client.BindDeviceAsync(new QqqDeviceCredential("device-1", "proof"));
|
||||
Task<HttpResponseMessage> printing = client.SendPrintingAsync(HttpMethod.Get, "device/jobs/job-1/content");
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(60));
|
||||
using HttpResponseMessage control = await client.SendPrintingAsync(HttpMethod.Post, "device/control/poll").WaitAsync(TimeSpan.FromSeconds(60));
|
||||
await client.SignOutAsync().WaitAsync(TimeSpan.FromSeconds(60));
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
using HttpResponseMessage control = await client.SendPrintingAsync(HttpMethod.Post, "device/control/poll").WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await client.SignOutAsync().WaitAsync(TimeSpan.FromSeconds(5));
|
||||
pending.SetResult(Response("{}"));
|
||||
await Assert.ThrowsAsync<QqqSessionException>(async () => { using HttpResponseMessage response = await printing; });
|
||||
Assert.False(client.IsAuthenticated);
|
||||
|
||||
@@ -73,7 +73,7 @@ public sealed class RealtimeTransportTests
|
||||
public async Task NativeCookieAndDeviceProofUpgradeKeepsReceivingDuringStalledDocument()
|
||||
{
|
||||
using var peer = new LoopbackWebSocketPeer();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
using var body = new BlockingDocument();
|
||||
using var handler = new NativeHandler(body);
|
||||
using var session = new QqqSessionClient(peer.Origin, handler, allowLoopbackHttp: true);
|
||||
|
||||
Reference in new issue
Block a user