Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a695996091
|
||
|
|
5d4b7f43ce
|
||
|
|
adf885beaf
|
||
|
|
c4b14963d3
|
||
|
|
a586ea7bf8
|
||
|
|
9906002d65
|
||
|
|
f7a9f79ab5
|
||
|
|
da43fa2918
|
||
|
|
b5831302d7
|
||
|
|
22b942b5e8
|
||
|
|
40872ab002
|
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
|
||||
@@ -36,6 +49,8 @@ reset with the connection. The activity log viewer has a Clear logs button, the
|
||||
result banner above the form and shows a waiting state during browser sign-in, and the service's sign-in reply
|
||||
no longer repeats itself. No wire change.
|
||||
|
||||
## 1.1.1
|
||||
|
||||
Browser sign-in fix. The companion runs elevated, and its callback inbox pipe was keyed with
|
||||
PipeOptions.CurrentUserOnly to the elevated token's default owner (the Administrators group), so
|
||||
the protocol handler the browser launches as the ordinary user was refused with "Access to the
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
|
||||
Print from **QQQ 4.1+** to local Windows printers without opening inbound network ports. The Windows service opens an authenticated two-way WebSocket to QQQ for commands, status and job notifications. Documents download separately over HTTPS, so a large transfer does not block the control connection. There is no job/control polling fallback.
|
||||
|
||||
**Current stable release: [1.0.1](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1).** The service, companion, CLI and MSI are signed with Azure Artifact Signing. The matching server release is [qbit-printing 1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0), using QQQ **4.1.0-RC.21** for native session login, with [protocol 1.0.0](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.0.0). Product versions are separate from native printing API **2.3**, wire payload **v1** and WebSocket subprotocol `qqq-printing.v1`.
|
||||
**Current stable release: [1.2.1](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1)** (generic and GG Forge edition installers). The service, companion, CLI and MSI are signed with Azure Artifact Signing. The matching server release is [qbit-printing 1.1.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.0), using QQQ **4.1.0-RC.21** for native session login, with [protocol 1.1.0](https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.1.0); 1.2.1 also works unchanged against qbit-printing 1.0.0. Product versions are separate from native printing API **2.4** (2.3 compatible), wire payload **v1** and WebSocket subprotocol `qqq-printing.v1`.
|
||||
|
||||
The setup/status application uses the same [KofTwentyTwo AppKit](https://git.kof22.com/KofTwentyTwo/AppKit) foundation as GCLO and MarkdownViewr.Windows. QQQ's mascot Query appears in About and branded warehouse test documents.
|
||||
|
||||
## Install and connect
|
||||
|
||||
1. Download `QqqPrintAgent-v1.0.1-x64.msi` and `SHA256SUMS` from the [stable release](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1). Check the checksum and Authenticode signature as described in [Installation](docs/INSTALLATION.md).
|
||||
1. Download `QqqPrintAgent-v1.2.1-x64.msi` (or a client edition such as `GgForgePrintAgent-v1.2.1-x64.msi`; one edition per PC) and `SHA256SUMS` from the [stable release](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1). Check the checksum and Authenticode signature as described in [Installation](docs/INSTALLATION.md).
|
||||
2. Install with administrator rights. The MSI installs the automatic Windows service and adds **QQQ Print Agent** under **Print Connectors** in Start.
|
||||
3. Open the app and accept its administrator prompt. Choose **Settings → Server connection → Sign in / Connection settings**, enter the QQQ HTTPS server address and select **Discover sign-in options**. Sign in using the advertised username/password or browser flow.
|
||||
4. For a new device, create an enrollment code in QQQ under **Printing → Print devices → Actions → Enroll a print device**, then enter it in the client. Existing pairings are reused or explicitly associated with the signed-in account; routine upgrades do not need a replacement pairing.
|
||||
@@ -55,7 +55,7 @@ The client reports the Windows queue's machine-default media as a revision-bound
|
||||
|
||||
## Requirements and limits
|
||||
|
||||
| Area | Stable 1.0.1 behavior |
|
||||
| Area | Stable 1.2.1 behavior |
|
||||
| --- | --- |
|
||||
| Server | QQQ 4.1+; recommended cohort QQQ 4.1.0-RC.21 and qbit-printing 1.0.0, native printing API 2.3 |
|
||||
| Windows | x64; Windows 11 used for hardware acceptance; API target Windows 10 build 19041+ |
|
||||
@@ -87,7 +87,7 @@ git clone https://git.kof22.com/QRun-IO/qqq-print-protocol.git
|
||||
git -C qqq-print-protocol checkout 42c0d249f81395f00408ec0f92638dcbbf5deb03
|
||||
Set-Location qqq-print-agent
|
||||
./build/Verify.ps1
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.0.1 -ReleaseVersion 1.0.1
|
||||
./build/Build-Installer.ps1 -ProductVersion 1.2.1 -ReleaseVersion 1.2.1
|
||||
```
|
||||
|
||||
Run verification only on a permitted Windows test host; host-specific restrictions remain in force. Local packaging is unsigned unless explicitly signed; stable release signing runs through the protected workflow. Neither command installs the service or sends a physical print.
|
||||
@@ -96,7 +96,7 @@ Run verification only on a permitted Windows test host; host-specific restrictio
|
||||
|
||||
## Release evidence
|
||||
|
||||
The signed 1.0.1 release passed its protected build, test, security, CodeQL, script and signing workflow. Published checksums, signatures and provenance were verified. The local 1.0.1 MSI upgrade completed successfully on 2026-11-03 with matching app/service/CLI versions and a running service. Dated hardware and earlier upgrade evidence remains in [Acceptance](docs/ACCEPTANCE.md); current release status is in [Release readiness](docs/RELEASE-READINESS.md). Earlier test results retain their original version and scope.
|
||||
The signed 1.2.1 release passed its protected build, test, security, CodeQL, script and per-edition signing workflow (run 42223525626); checksums, Authenticode signatures and provenance attestations were verified on the published generic and GG Forge installers, and the GG Forge installer was upgraded in place on the development PC with pairing, session and settings preserved. The earlier signed 1.0.1 release passed its protected build, test, security, CodeQL, script and signing workflow. Published checksums, signatures and provenance were verified. The local 1.0.1 MSI upgrade completed successfully on 2026-11-03 with matching app/service/CLI versions and a running service. Dated hardware and earlier upgrade evidence remains in [Acceptance](docs/ACCEPTANCE.md); current release status is in [Release readiness](docs/RELEASE-READINESS.md). Earlier test results retain their original version and scope.
|
||||
|
||||
## License and acknowledgments
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Printing cohort handoff for the Forge agent — 2026-11-05
|
||||
|
||||
Reusable changes were made in the printer client and the printing QBit repositories. Existing
|
||||
authentication, discovery, enrollment, inventory and printing contracts are preserved; there are
|
||||
**no breaking changes**. Nothing was installed over Forge's working client or dependencies; adoption,
|
||||
pinning and the pairing tests (browser sign-in, session resume, enrollment, server printing,
|
||||
TC22 → Echo → Z2) are yours. The previous cohort stays available for rollback.
|
||||
|
||||
## Exact versions and artifacts
|
||||
|
||||
| Component | Version | Tag / source | Artifact | SHA-256 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Protocol (contract + fixtures) | 1.1.0 | `v1.1.0` at `5005f3c9e27053b6c327bb923695012832c0fdcc` | https://git.kof22.com/QRun-IO/qqq-print-protocol/releases/tag/v1.1.0 — `qqq-print-protocol-1.1.0.tar.gz` | `541f0f3054cbfe9ce16e7dea58fad01b55a7f0e9246ade8f30df1bb826940703` |
|
||||
| Printing QBit (server) | **1.1.2**, native printing extension **2.4.0**, on QQQ **4.1.0-RC.22** from the Gitea registry (1.1.1 is the same code on RC.21 from Maven Central; 1.1.0 is superseded: do not adopt it) | `v1.1.2` at `f5639a98be9a2f15d4c61cf0955fe4ecef85824f`, push build 6435 + tag build 6475 | Maven `com.kingsrook.qbits:qbit-printing:1.1.2` from `https://git.kof22.com/api/packages/QRun-IO/maven` (repository id `qrun-io`, reader token); same bytes on https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.2 | JAR `786e6e570c5df29b01529562efb246a4f4562bb1d9fff6bea77d264e0d7536ce`, POM `7691a4e1cb7b03c7a559b0e2b92eacd92f86545b98bddafac86fc70f8602b03d` |
|
||||
| Windows print client | **1.2.1** (1.2.0 content + release-pipeline fix; tag v1.2.0 produced no installer) | `v1.2.1` at `3b9d75d18f9cc8b80f4cf6d5aae81cccf8d61243`, GitHub release run 42223525626 | Signed installers: `QqqPrintAgent-v1.2.1-x64.msi` (generic) and `GgForgePrintAgent-v1.2.1-x64.msi` (GG Forge edition), `SHA256SUMS`, on https://github.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1 and byte-identical on https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1 | generic `09920872b3447f8679b404f2b5ebd7d9fe82204b8007db63d58e4287f436f2eb`, GG Forge `5ce489092c1403449174b8a35eda039cd452e8685cbfe436d99aea3e15a5d8b4`; Authenticode valid (CN=James Maes, Microsoft timestamp); GitHub provenance attestation verified |
|
||||
|
||||
Previous cohort for rollback: protocol 1.0.0, QBit 1.0.0 (extension 2.3.0), Windows 1.0.1 / 1.1.1.
|
||||
|
||||
**2026-11-06 update (QBit 1.1.1).** Forge's qualification of 1.1.0 found that the realtime upgrade fails before WELCOME behind an ingress that lowercases header names (the device id was read case-sensitively from the WebSocket context; the same defect exists in 1.0.0, which Forge ran with a local patch). 1.1.1 resolves the device once on the authorized upgrade and carries it on the connection, with a regression test that connects with lowercase header names. No contract change: the pairing matrix and everything else in this handoff is unchanged; replace the `1.0.0-forge…headers1` coordinate with the published 1.1.1 and drop the local patch.
|
||||
|
||||
**2026-11-06 update (QBit 1.1.2, Gitea-only runtime).** James's direction: every QRun artifact resolves from the QRun-IO Gitea registry, no more Maven Central. 1.1.2 is the 1.1.1 code certified on QQQ **4.1.0-RC.22** (the core version Forge already pins; it exists only in the Gitea registry) with `-Pui-demo verify`, 200 tests, 0 failures, and published by the shared `ci-workflows` pipeline. `qqq-frontend-next` 1.0.0-RC.37 is also mirrored into the registry byte-for-byte from Central. Pin `com.kingsrook.qbits:qbit-printing:1.1.2` together with core 4.1.0-RC.22 and read both from `https://git.kof22.com/api/packages/QRun-IO/maven`; third-party libraries still come through the Maven Central mirror until the registry proxies them. No contract change; pairing matrix unchanged.
|
||||
|
||||
## What changed, by contract
|
||||
|
||||
- **Printer approval over the realtime channel (extension 2.4.0, additive).** Client advertises
|
||||
`printer-approval-v1` in HELLO only when discovery shows a stable printing 2.x with minor >= 4.
|
||||
Server then sends a `PRINTERS` frame (`{ "printers": [ { "printerId", "enabled" } ] }`, the complete
|
||||
approval state of the device's exposed printers) after each accepted INVENTORY and whenever an
|
||||
administrator approves or disables one of that device's printers. Not acknowledged. A 2.3 peer on
|
||||
either side keeps the exact 1.0 wire: 1.0.x/1.1.x clients never advertise it and never receive it;
|
||||
the 1.2.1 client against QBit 1.0.0 reads the operator catalog instead (user-scoped; shows "Not
|
||||
reported" without operator permission).
|
||||
- **Remove printers / Remove device (QBit).** Archives with `removed_at`; hidden from lists, catalog,
|
||||
claims and the PRINTERS frame; a re-shared printer returns as awaiting approval; retention purges
|
||||
removed rows that no job references. QQQ 4.1 has no table-level default filter, so the raw Printers
|
||||
/ Print devices tables still show removed rows until a "Removed at is empty" saved view is added.
|
||||
- **Client sign-in nonce.** Sends `expectedNonce` under REQUIRED, OPTIONAL and the discovery capability
|
||||
`nativeSessionNonceValidation`; legacy servers unchanged (PR 11 content, already merged earlier).
|
||||
- **Client UX/ops.** Server column on the Printers page; verbose sign-in diagnostics with DEBUG level;
|
||||
log size rollover; per-server untrusted-certificate option (development only, origin-bound);
|
||||
connection dialog rework; Clear logs button in the log viewer; client editions (GG Forge build is a
|
||||
separate MSI with its own update asset; one edition per PC, shared UpgradeCode).
|
||||
|
||||
## Pairing matrix (what must hold in your tests)
|
||||
|
||||
| Server | Client | Expected |
|
||||
| --- | --- | --- |
|
||||
| QBit 1.0.0 (2.3.0) | 1.2.1 | No `printer-approval-v1` in HELLO; catalog fallback; everything else as 1.1.1 |
|
||||
| QBit 1.1.2 (2.4.0) | 1.0.1 / 1.1.1 | Four-capability HELLO at most; no PRINTERS frame; unchanged behaviour |
|
||||
| QBit 1.1.2 (2.4.0) | 1.2.1 | Five-capability HELLO; PRINTERS after inventory and on approval change; Printers page shows Approved / Awaiting approval live |
|
||||
|
||||
## Evidence
|
||||
|
||||
- Protocol: `npm run check` (79 schema definitions, 82 fixtures), lint, typecheck, quality all green on 5005f3c.
|
||||
- QBit: `mvnw -Pui-demo verify` 199 tests, 0 failures, 0 Checkstyle; main run 5473 verify + secrets + gate + post-publish green; registry files read back byte-for-byte.
|
||||
- Client: hosted GitHub CI run 42116396114 on the PR head (197 Core + 362 Windows tests, 0 failures); Gitea required checks green on both merges; release run 42223525626 (verify, security, CodeQL, sign generic + GG Forge, assemble, publish).
|
||||
|
||||
## Notes for adoption
|
||||
|
||||
- Pin all three together. The client's CI fixture pin is `ac835f8b9396b641b525669249fdb4a1e2a50c99`; the QBit's is `5005f3c9e27053b6c327bb923695012832c0fdcc` (docs PRs 29 and 12 pending).
|
||||
- Forge must approve the shared printers on the server after the first inventory from a 1.2.0 client, as before; the difference is that the client now shows that state.
|
||||
- Coordinate installation on the Forge print station yourself; the GG Forge rc.3 build on James's PC is a local test build (file version 1.2.0.3) and will update to the signed 1.2.1 from its own GG Forge asset.
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Gitea is primary for source branches, reviews and normal pull requests. GitHub is the automatic source shadow. The existing authorized [GitHub release workflow](../.github/workflows/release.yml), its CI/security dependencies and the GitHub release assets remain the signing and installed-updater bridge. Do not retire those Windows workflows or remove the feed as part of ordinary source migration.
|
||||
|
||||
Current stable release: **[1.0.1 on Gitea](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1)** and the matching **[GitHub assets](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v1.0.1)**. The protected [release run](https://github.com/QRun-IO/qqq-print-agent/actions/runs/41532214447) completed signing and publication. The earlier immutable `v1.0.0` and `v1.2.0` tags produced no signed installer (`v1.2.0`: the edition sign job's array splat, fixed in 1.2.1); they must not be moved or reused.
|
||||
Current stable release: **[1.2.1 on Gitea](https://git.kof22.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1)** and the matching **[GitHub assets](https://github.com/QRun-IO/qqq-print-agent/releases/tag/v1.2.1)** (generic and GG Forge installers). The protected [release run](https://github.com/QRun-IO/qqq-print-agent/actions/runs/42223525626) completed signing and publication; checksums, Authenticode signatures and provenance attestations were verified on the downloaded assets (generic `09920872…`, GG Forge `5ce48909…`). The earlier immutable `v1.0.0` and `v1.2.0` tags produced no signed installer (`v1.2.0`: the edition sign job's array splat, fixed in 1.2.1); they must not be moved or reused. Previous stable: 1.1.1.
|
||||
|
||||
## Stable releases
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
# Session state — 2026-11-05 (evening)
|
||||
# Session state — 2026-11-06
|
||||
|
||||
## Current — 1.2.1 released; printing cohort protocol 1.1.0 / QBit 1.1.0 / client 1.2.1 complete
|
||||
|
||||
Client 1.2.1 (source `3b9d75d18f9cc8b80f4cf6d5aae81cccf8d61243`, GitHub release run 42223525626) is published with signed generic and GG Forge installers on GitHub and, byte-identical, on Gitea; see [RELEASING](RELEASING.md). Protocol 1.1.0 and QBit 1.1.0 (extension 2.4.0, including Remove printers / Remove device) are published. The handoff for the Forge agent, with every version, artifact and hash, is [FORGE-HANDOFF-2026-11-05.md](FORGE-HANDOFF-2026-11-05.md); Forge adoption, pairing tests and installation on its station are that lane's. The installed GG Forge rc.3 on this PC updates to 1.2.1 from its own asset through the in-app updater (not yet exercised). Open elsewhere: Gitea CI quota/runner-fleet work (Praetor lane); docs PRs qbit-printing 29 and qqq-print-protocol 12.
|
||||
|
||||
|
||||
## 1.2.1 — release-pipeline fix after the v1.2.0 tag produced no signed installer
|
||||
|
||||
|
||||
+12
-2
@@ -1,6 +1,16 @@
|
||||
# TODO
|
||||
|
||||
## Active: required-nonce native sign-in fix — 2026-11-05
|
||||
## Released: 1.2.1 — 2026-11-06
|
||||
|
||||
Client 1.2.1 (source `3b9d75d`, GitHub release run 42223525626) is the current stable: signed generic and GG Forge installers on GitHub and, byte-identical, on Gitea. See [RELEASING](RELEASING.md).
|
||||
|
||||
- [x] Nonce fix PR 11 (`abc3d0d`) and the OPTIONAL/discovery nonce change (`c59814d`) shipped in `v1.2.0`/`v1.2.1`.
|
||||
- [x] Release run 42128511730 on `v1.2.0` failed in `sign (gg-forge)`: `release.yml` array-splatted `-Edition`, binding it positionally to `-CertificateThumbprint`. Fixed in PR 12 (`3b9d75d`); `v1.2.0` stays immutable and unpublished.
|
||||
- [x] `v1.2.1` tagged on the merge commit, mirrored to GitHub, signed, assembled and published; checksums, signatures and provenance verified; same assets attached to the Gitea release.
|
||||
- [x] Post-merge main checks (ci, codeql, security, scripts, scorecard) green on `b583130`.
|
||||
- [ ] Actual native sign-in/sign-out acceptance against the shared provider (the shared-fixture discovery pass is not acceptance).
|
||||
|
||||
## Historical: required-nonce native sign-in fix — 2026-11-05 (published in 1.2.x)
|
||||
|
||||
The stable printing release below remains accepted historical evidence. The shared-server scanner Authentik requires this fix (`fix/native-login-required-nonce`, integrated on main `f954f11`).
|
||||
|
||||
@@ -9,7 +19,7 @@ The stable printing release below remains accepted historical evidence. The shar
|
||||
- [x] Integrated NativeCI Release build: zero warnings/errors; 352 Windows/CLI tests passed with no failures/skips; source notices passed for 300 tracked files. Existing method-length limit retained; Core.Tests excluded locally.
|
||||
- [x] Hosted CI including Core.Tests: GitHub CI on the editions branch carrying this fix passed 182 Core and 352 Windows/CLI tests (run 42078731426, 2026-11-05).
|
||||
- [ ] Real shared-provider native login acceptance.
|
||||
- [ ] Final published build.
|
||||
- [x] Final published build: 1.2.1 (see above).
|
||||
|
||||
## Closed product release — 2026-11-04
|
||||
|
||||
|
||||
@@ -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