Merge pull request 'fix(auth): sign-out ends the identity-provider session; sign-in sends max_age=0' (#18) from fix/signout-ends-provider-session into main
This commit was merged in pull request #18.
This commit is contained in:
16 files changed
+668
-11
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user