Compare commits

...
13 Commits
Author SHA1 Message Date
james.maes a695996091 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 2026-11-09 20:46:23 +00:00
james.maesandClaude Sonnet 5.5 5d4b7f43ce fix(auth): sign-out ends the identity-provider session; sign-in sends max_age=0
Sign out only ended the QQQ session, so a retained provider session silently
signed the next person in. The companion now asks the service to log out with
the registered callback; the server's provider end-session data is checked
against the advertised issuer and the provider's own discovery, then opened in
the browser. Browser sign-in also sends max_age=0 beside prompt=login. The
command line still ends only the QQQ session and says so.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:42:43 -06:00
james.maes adf885beaf Merge pull request 'docs(changelog): restore the 1.1.1 heading; record 1.2.1 in TODO' (#15) from docs/changelog-1.1.1-heading into main
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (push) Failing after 1m54s
QRun-IO/ci-policy: ci / source-config-14 (push) Failing after 2m30s
QRun-IO/ci-policy: ci / ci-result (push) Failing after 8s
QRun-IO/ci-policy: native / dotnet-0 (push) Successful in 20m4s
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (push) Successful in 13s
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (push) Successful in 30s
QRun-IO/ci-policy: Shared .NET CI / verify (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (push) Successful in 22s
QRun-IO/ci-policy: Shared source and config CI / secrets (push) Successful in 33s
QRun-IO/ci-policy: Shared .NET CI / gate (push) Successful in 12s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / hosted (push) Successful in 19m50s
2026-11-06 23:23:40 +00:00
james.maes c4b14963d3 Merge pull request 'docs(handoff): point the Forge printing cohort at QBit 1.1.2 on QQQ RC.22 from the Gitea registry' (#16) from docs/forge-handoff-qbit-112-20261106 into main
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (push) Successful in 22s
QRun-IO/ci-policy: ci / source-config-14 (push) Successful in 45s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 9s
QRun-IO/ci-policy: native / dotnet-0 (push) Failing after 9m42s
QRun-IO/ci-policy: native / native-result (push) Failing after 8s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (push) Successful in 11s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (push) Successful in 25s
QRun-IO/ci-policy: Shared .NET CI / verify (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (push) Successful in 20s
QRun-IO/ci-policy: Shared source and config CI / secrets (push) Successful in 21s
QRun-IO/ci-policy: Shared .NET CI / gate (push) Failing after 20s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / hosted (push) Failing after 9m19s
2026-11-06 20:59:13 +00:00
james.maesandClaude Fable 5.1 a586ea7bf8 docs(handoff): point the Forge printing cohort at QBit 1.1.2 on QQQ RC.22 from the Gitea registry
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 15s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 24s
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 36s
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 29s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 11s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 10m50s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 24s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 26s
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 11m22s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 1m11s
Same code as 1.1.1, certified on 4.1.0-RC.22 (Forge's core pin) and published by the
shared pipeline; exact tag, builds, registry coordinate and verified hashes recorded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 14:45:48 -06:00
james.maes 9906002d65 Merge pull request 'docs(handoff): point the Forge printing cohort at QBit 1.1.1' (#14) from docs/forge-handoff-qbit-111-20261106 into main
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (push) Successful in 32s
QRun-IO/ci-policy: ci / source-config-14 (push) Successful in 1m18s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 10s
QRun-IO/ci-policy: native / dotnet-0 (push) Successful in 14m1s
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (push) Successful in 10s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (push) Successful in 21s
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (push) Successful in 20s
QRun-IO/ci-policy: Shared source and config CI / secrets (push) Successful in 38s
QRun-IO/ci-policy: Shared .NET CI / gate (push) Successful in 14s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / hosted (push) Successful in 13m46s
2026-11-06 16:50:07 +00:00
james.maesandClaude Fable 5.1 f7a9f79ab5 docs(changelog): restore the 1.1.1 heading; record 1.2.1 in TODO
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 26s
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 31s
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 20s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 9s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 12m20s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 18s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 12m40s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 45s
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 57s
The editions branch folded the published 1.1.1 release's browser sign-in
fix into the 1.2.0 CHANGELOG entry. Put the `## 1.1.1` heading back so the
changelog matches the published releases. Refresh docs/TODO.md: 1.2.1 is
the current stable, the v1.2.0 sign-job failure is closed by PR 12, and
the nonce section is historical. Docs only; no source or workflow change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 10:41:24 -06:00
james.maesandClaude Fable 5.1 da43fa2918 docs(handoff): point the Forge printing cohort at QBit 1.1.1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 46s
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 46s
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 26s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 14s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 13m57s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 19s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 14m19s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 40s
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 1m24s
1.1.1 supersedes 1.1.0 (realtime upgrade header-case fix, no contract change).
Exact tag, run, registry coordinates and verified JAR/POM hashes recorded for the
Forge agent; pairing matrix updated to name 1.1.1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 10:34:31 -06:00
james.maes b5831302d7 Merge pull request 'docs(release): record 1.2.1 as current stable; Forge handoff' (#13) from docs/release-1.2.1-20261106 into main
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Successful in 14m12s
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (push) Successful in 10s
QRun-IO/ci-policy: ci / source-config-14 (push) Successful in 28s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / gate (push) Successful in 19s
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (push) Successful in 14s
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (push) Successful in 12s
QRun-IO/ci-policy: Shared source and config CI / secrets (push) Successful in 14s
QRun-IO/ci-policy: Shared .NET CI / hosted (push) Successful in 13m46s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
2026-11-06 14:09:03 +00:00
james.maesandClaude Fable 5.1 22b942b5e8 docs(readme): current stable 1.2.1 with the 1.1.0 server and protocol cohort
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 12m12s
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 20s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 13s
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 10s
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 12m29s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 13s
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 41s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 07:54:09 -06:00
james.maesandClaude Fable 5.1 40872ab002 docs(release): record 1.2.1 as the current stable release and add the Forge handoff
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 10m41s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 12s
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 11s
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 10m51s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 13s
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 23s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 07:31:00 -06:00
james.maes 3b9d75d18f Merge pull request 'fix(release): hashtable splat for the per-edition sign job; 1.2.1' (#12) from fix/release-edition-splat-20261105 into main
QRun-IO/ci-policy: native / dotnet-0 (push) Successful in 13m52s
QRun-IO/ci-policy: native / native-result (push) Successful in 4s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / portable (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / gate (push) Successful in 7s
QRun-IO/ci-policy: ci / source-config-14 (push) Successful in 24s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
QRun-IO/ci-policy: Shared .NET CI / gate (push) Successful in 10s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (push) Successful in 11s
QRun-IO/ci-policy: Shared .NET CI / verify (push) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (push) Successful in 9s
QRun-IO/ci-policy: Shared source and config CI / secrets (push) Successful in 11s
QRun-IO/ci-policy: Shared .NET CI / hosted (push) Successful in 13m41s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
2026-11-06 13:08:06 +00:00
james.maesandClaude Fable 5.1 6404922e1e fix(release): splat the edition as a hashtable so the per-edition sign job can publish; 1.2.1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / verify (pull_request) Skipped
QRun-IO/ci-policy: Shared source and config CI / verify (pull_request) Successful in 19s
QRun-IO/ci-policy: Shared source and config CI / secrets (pull_request) Successful in 24s
QRun-IO/ci-policy: Shared source and config CI / gate (pull_request) Successful in 17s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
QRun-IO/ci-policy: Shared .NET CI / hosted (pull_request) Successful in 13m47s
QRun-IO/ci-policy: Shared .NET CI / gate (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared .NET CI / secrets (pull_request) Successful in 17s
QRun-IO/ci-policy: Shared .NET CI / portable (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Successful in 14m0s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Successful in 47s
The stable release workflow passed "-Edition gg-forge" to Build-Installer.ps1
through an array splat. PowerShell binds array-splatted elements positionally,
so the two strings landed in CertificateThumbprint and TimestampServer and the
PublishOnly guard refused the GG Forge sign job (run 42128511730); the generic
job only survived because its array was empty. A hashtable splat binds by name.
Tag v1.2.0 produced no signed installer and stays immutable, so the product
moves to 1.2.1 with the same source otherwise.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-05 17:32:15 -06:00
26 changed files with 783 additions and 31 deletions

No files matched your search

+4 -4
View File
@@ -12,10 +12,10 @@ on:
inputs:
product-version:
type: string
default: '1.2.0'
default: '1.2.1'
release-version:
type: string
default: '1.2.0'
default: '1.2.1'
permissions:
contents: read
@@ -59,8 +59,8 @@ jobs:
./build/Test-PackagingInputs.ps1
- name: Build unsigned prerelease MSI
env:
PRODUCT_VERSION: ${{ inputs.product-version || '1.2.0' }}
RELEASE_VERSION: ${{ inputs.release-version || '1.2.0' }}
PRODUCT_VERSION: ${{ inputs.product-version || '1.2.1' }}
RELEASE_VERSION: ${{ inputs.release-version || '1.2.1' }}
run: |
./build/Build-Installer.ps1 -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+6 -2
View File
@@ -166,7 +166,9 @@ jobs:
Write-Output "Edition: '$env:EDITION' -> $prefix"
- name: Publish and check the payload
run: |
$editionArgs = if ($env:EDITION_ARGS) { $env:EDITION_ARGS.Split(' ') } else { @() }
# Hashtable splat: an array splat binds "-Edition gg-forge" positionally (to CertificateThumbprint/TimestampServer) and trips the PublishOnly guard.
$editionArgs = @{}
if ($env:EDITION) { $editionArgs['Edition'] = $env:EDITION }
./build/Build-Installer.ps1 -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION -PublishOnly -PayloadDirectory "$env:RUNNER_TEMP/payload" @editionArgs
- name: Stage owned binaries
run: ./build/Sign-Release.ps1 -Operation Stage -Kind Payload -Root "$env:RUNNER_TEMP/payload" -Stage "$env:RUNNER_TEMP/sign-payload"
@@ -212,7 +214,9 @@ jobs:
run: ./build/Sign-Release.ps1 -Operation Apply -Kind Payload -Root "$env:RUNNER_TEMP/payload" -Stage "$env:RUNNER_TEMP/sign-payload" -ExpectedSubject $env:ARTIFACT_SIGNING_SUBJECT
- name: Build MSI from the signed payload
run: |
$editionArgs = if ($env:EDITION_ARGS) { $env:EDITION_ARGS.Split(' ') } else { @() }
# Hashtable splat: an array splat binds "-Edition gg-forge" positionally (to CertificateThumbprint/TimestampServer) and trips the PublishOnly guard.
$editionArgs = @{}
if ($env:EDITION) { $editionArgs['Edition'] = $env:EDITION }
./build/Build-Installer.ps1 -ProductVersion $env:PRODUCT_VERSION -ReleaseVersion $env:RELEASE_VERSION -SkipPublish -PayloadDirectory "$env:RUNNER_TEMP/payload" @editionArgs
- name: Stage MSI
run: ./build/Sign-Release.ps1 -Operation Stage -Kind Msi -Root $env:MSI_PATH -Stage "$env:RUNNER_TEMP/sign-msi"
+23
View File
@@ -1,5 +1,26 @@
# 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
sign job passed the edition to `Build-Installer.ps1` through an array splat, which PowerShell binds
positionally, so `-Edition gg-forge` landed in the signing-certificate parameters and the GG Forge
sign job refused to publish; tag `v1.2.0` therefore produced no signed installer and must not be
moved or reused. The workflow now splats a hashtable. No application or wire change.
## 1.2.0
Client editions. A folder under `editions/<id>` (descriptor, SVG mark, ICO icon, optional logo) and
@@ -28,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
+1 -1
View File
@@ -60,7 +60,7 @@
commit as "+<sha>" on AssemblyInformationalVersion, which the About dialog and the
CLI's version output show as a short hash. -->
<PropertyGroup>
<VersionPrefix>1.2.0</VersionPrefix>
<VersionPrefix>1.2.1</VersionPrefix>
<VersionSuffix></VersionSuffix>
</PropertyGroup>
+5 -5
View File
@@ -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
+2 -2
View File
@@ -14,9 +14,9 @@ bundle with SHA256SUMS. It takes no certificate, timestamp server or payload inp
[CmdletBinding()]
param(
[ValidatePattern('^\d+\.\d+\.\d+$')]
[string] $ProductVersion = '1.2.0',
[string] $ProductVersion = '1.2.1',
[ValidatePattern('^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$')]
[string] $ReleaseVersion = '1.2.0'
[string] $ReleaseVersion = '1.2.1'
)
$ErrorActionPreference = 'Stop'
+2 -2
View File
@@ -4,8 +4,8 @@
[CmdletBinding()]
param(
[string] $ProductVersion = '1.2.0',
[string] $ReleaseVersion = '1.2.0',
[string] $ProductVersion = '1.2.1',
[string] $ReleaseVersion = '1.2.1',
# Assemble artifacts/release from an already built MSI (the signed stable flow builds and signs it in
# separate workflow steps). With ExpectedPublisherSubject the MSI must carry that publisher's signature.
[switch] $AssembleOnly,
+62
View File
@@ -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
View File
@@ -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` tag produced no signed installer; it 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
+10 -1
View File
@@ -1,4 +1,13 @@
# 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
Protocol 1.1.0 and QBit 1.1.0 are published (see below). PR 10 merged as `c8ea97e` and tag `v1.2.0` was mirrored; GitHub release run 42128511730 failed in `sign (gg-forge)` at "Publish and check the payload": the workflow splatted `-Edition gg-forge` as an array, which PowerShell binds positionally into `CertificateThumbprint`/`TimestampServer`, so `Build-Installer.ps1` refused `-PublishOnly`. Fixed with a hashtable splat (local reproduction of the step publishes the GG Forge payload); version bumped to 1.2.1 because `v1.2.0` is immutable and must not be reused. Next: merge, tag `v1.2.1` on Gitea, mirror, signed release, Gitea mirror of the assets, Forge handoff.
## Printer approval over the realtime channel (extension 2.4.0) — implemented across three repositories, merges pending
+12 -2
View File
@@ -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
+2
View File
@@ -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.
+4
View File
@@ -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(); }
}
+1 -1
View File
@@ -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":
+1 -1
View File
@@ -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);