From e8aa8e4f199869475ad592d98b772498dd1a953e Mon Sep 17 00:00:00 2001 From: Kanushka Gayan Date: Thu, 6 Aug 2026 10:06:36 +0530 Subject: [PATCH 1/2] test: make the smoke run's narrowing check able to fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live run asked the broker for config.Scopes — every permission the session already carries — so internal/auth/narrowing.go compared the issued scopes against an identical request. That comparison holds however the deployment behaved. A backend that disregarded the narrowing request entirely would still have been reported as granted, and the definition of done asks for a backend *proven* to satisfy the scope policy. The audience half of the same verification was real and had caught a live misconfiguration. The scope half could not fail. config.NarrowTarget() had existed for this since the package was written and nothing called it. So the run now acquires twice. The first request is the broad one and proves the broker can derive access at all, which is what a first run against a new deployment needs to know. The second asks for one permission out of the several the session holds — the request a module actually makes, and the only form the verification can disagree with. A refusal on the broad request stops the run rather than repeating one finding, because a deployment that will not issue against this audience at all says nothing about narrowing. Reusing one broker for both refused with auth.already_granted: a module gets one acquisition per command. Two invocations is not a way around that rule but the accurate model of it — two commands, in turn, against one session. Against a rotating deployment the second acquisition now also proves the first persisted its replacement, since it can only reach the token endpoint with what the first one stored. Measured against wso2/wso2is:7.3.0: 1261 characters carrying both permissions, then 1230 carrying one. Two tokens the previous single-acquisition form could not have told apart. The module half of the same definition-of-done clause needs nothing further. A module never learns which issuer minted its token, so running it against a live deployment proves nothing TestLoginThenTheModuleReceivesIssuerMintedNarrowedAccess does not already prove against the fake issuer, where the token is introspected and rotation is pinned across three runs. Claude-Session: https://claude.ai/code/session_01RojiAgW9hi3b9f9G6ZXBVp --- docs/plans/login-first-slice.md | 14 +++-- test/smoke/RUNNING.md | 42 ++++++++++++--- test/smoke/login_smoke_test.go | 95 ++++++++++++++++++++++++++++----- 3 files changed, 127 insertions(+), 24 deletions(-) diff --git a/docs/plans/login-first-slice.md b/docs/plans/login-first-slice.md index 7d1a793..d45a36d 100644 --- a/docs/plans/login-first-slice.md +++ b/docs/plans/login-first-slice.md @@ -1,9 +1,13 @@ # `wso2 login` First Slice Implementation Plan **Status:** Implemented. Tasks 1-12 are merged into `feature/login` through PRs -#24-#29, #33 and #34, and every step below is ticked. One definition-of-done -check is still open and says why in place: the live smoke run's scope check -cannot fail as written. +#24-#29, #33, #34 and #35, and every box below is ticked, definition of done +included. + +One finding outran the plan and is recorded under the definition of done: an +access token's audience can name the product resource on Identity Server and +structurally cannot on Asgardeo, which is a decision for issue #17 rather than a +task here. **Goal:** Implement `wso2 login` (browser Authorization Code + PKCE) and inline client-credentials acquisition, on a version-2 identities/contexts schema, with OS-keychain session storage and a token-source seam in the broker, so the reference module receives a real issuer-minted access token. @@ -1578,9 +1582,9 @@ git commit -m "docs: add the login walkthrough, live smoke gates, and empirical - [x] `wso2 login` completes PKCE against a real Asgardeo trial tenant and a local IS 7.x (`make smoke-login`, run twice with the two issuer configs). — Asgardeo tenant 2026-08-06; `wso2/wso2is:7.3.0` 2026-08-06. The nonce echo is checked by both, since `oauthflow/login.go` refuses on a mismatch and neither login could otherwise have completed. - [x] The refresh token lands in the OS secure store (smoke run; deterministic equivalent in Task 7). — both smoke runs read it back out and compare its issuer. -- [ ] The reference module receives a real short-lived access token through the broker against a backend proven to satisfy the scope/audience policy, and a test introspects that token (Task 11 deterministic; smoke against whichever real backend passes the empirical narrowing test). +- [x] The reference module receives a real short-lived access token through the broker against a backend proven to satisfy the scope/audience policy, and a test introspects that token (Task 11 deterministic; smoke against whichever real backend passes the empirical narrowing test). - **Deterministic half done, live half incomplete.** `TestLoginThenTheModuleReceivesIssuerMintedNarrowedAccess` launches the real module subprocess, introspects what it presented, and proves refresh rotation — against the fake issuer. Live, `login_smoke_test.go` acquires through the broker but asks for `config.Scopes`, every scope the session already holds, so `sameScopeSet` compares a set against itself: the audience check is real and the scope check cannot fail. A deployment that flatly ignored narrowing would still print `granted`. `config.NarrowTarget()` exists for exactly this and is unused by the smoke run. The module half needs nothing further — the module never learns which issuer minted its token, so running it live proves nothing the deterministic chain does not. + Deterministic: `TestLoginThenTheModuleReceivesIssuerMintedNarrowedAccess` launches the real module subprocess, has the issuer introspect what it presented, and proves refresh rotation across three runs. Live: `login_smoke_test.go` acquires twice, and the second acquisition asks for one permission out of the several the session holds. That second request is what makes the check capable of failing — asking for everything the session carries leaves the shell comparing the issued scopes against an identical request, which holds however the deployment behaved. Measured against Identity Server 7.3.0 on 2026-08-06: 1261 characters carrying both permissions, then 1230 carrying one. The module half needs nothing further, because a module never learns which issuer minted its token and running it live proves nothing the deterministic chain does not. - [x] If Asgardeo fails the narrowing experiment: login and session persistence still pass; broker acquisition refuses `auth.narrowing_unavailable`; the research doc records the verdict (Task 12). — moot in the favorable direction: both deployments honor narrowing (`make empirical-asgardeo`, 2026-08-06). The refusal path itself is pinned by `TestADeploymentThatCannotNarrowIsRefusedRatherThanGrantedMore`. - [x] CI path acquires a client-credentials token inline in an acceptance test (Task 11). — `TestAnInlineIdentityAuthenticatesACommandWithNoLoginStep`, plus the missing-secret, cannot-narrow, non-interactive and secret-disclosure cases beside it. - [x] `docs/research/asgardeo-redirect-uri-and-scope-narrowing.md` empirical cells filled in (Task 12). — Asgardeo §3 filled 2026-08-06; Identity Server 7.3.0 recorded in §3.1. diff --git a/test/smoke/RUNNING.md b/test/smoke/RUNNING.md index ec52c76..d1ad0bb 100644 --- a/test/smoke/RUNNING.md +++ b/test/smoke/RUNNING.md @@ -83,9 +83,31 @@ cp test/smoke/env.example test/smoke/.env # then fill it in make smoke-login ``` -A browser opens; sign in. The run then proves three things in order: `wso2 login` +A browser opens; sign in. The run then proves four things in order: `wso2 login` exits zero, the refresh token is readable back out of the operating system's -secure store, and the broker derives one access token from that session. +secure store, the broker derives an access token from that session, and it +derives a second one carrying strictly less than the session holds: + +``` +LOGIN SMOKE: granted — asked for everything the session carries, received access of + 1261 characters bound to "reference-status" carrying + [reference:status:read reference:status:write] +LOGIN SMOKE: narrowed — asked for one permission out of the 2 the session holds, + received access of 1230 characters bound to "reference-status" + carrying [reference:status:read] +``` + +The second line is the one that measures anything about narrowing. When the +request is every permission the session already carries, the shell compares the +issued scopes against an identical request, so that check holds however the +deployment behaved — a deployment that disregarded the request entirely would +still be reported as granted. Only a strict subset can fail, and a strict subset +is what a module actually asks for. + +The two acquisitions run as two separate invocations because the shell allows a +module one acquisition per command and refuses a second with +`auth.already_granted`. Against a deployment that rotates refresh tokens, the +second acquisition also proves the first persisted its replacement. Run it once per deployment. Two things change between them, and only one of them is obvious: @@ -107,12 +129,14 @@ any of this can reach it — see section 3.6 of the walkthrough. ### The one refusal that is not a failure -If the deployment will not prove a narrowed grant, the acquisition step reports: +If the deployment will not prove a grant is exactly what was asked for, the +acquisition step reports: ``` -LOGIN SMOKE: refused auth.narrowing_unavailable — the deployment would not prove -a narrowed grant. Login and session persistence passed; this refusal is the -designed outcome, not a failure. +LOGIN SMOKE: refused auth.narrowing_unavailable — asked for one permission out of +the 2 the session holds, and the shell would not hand the module a grant it could +not prove was exactly what it asked for. Login and session persistence passed; +this refusal is the designed outcome, not a failure. ``` and the run passes. The shell does not hand a module more authority than it @@ -120,6 +144,12 @@ asked for, so refusing is the correct behavior, not a fallback. The walkthrough' troubleshooting section explains what to change in the registration if you want a grant instead. +Read which acquisition refused. On the **narrowed** one it is a statement about +the deployment: it would not issue a token carrying strictly less than the +session. On the **broad** one it is almost always the registration instead — +most often an audience the deployment never binds — and the run stops there +rather than repeating one finding twice. + ## The experiments Run once per deployment, ever. They answer the two questions the research diff --git a/test/smoke/login_smoke_test.go b/test/smoke/login_smoke_test.go index 16a634d..11519c7 100644 --- a/test/smoke/login_smoke_test.go +++ b/test/smoke/login_smoke_test.go @@ -27,6 +27,7 @@ import ( "io" "os" "path/filepath" + "strconv" "testing" "github.com/wso2/wso2-cli/internal/app" @@ -40,9 +41,17 @@ import ( // TestLoginSmoke drives the whole slice against a deployment that really // exists: `wso2 login` through a browser a human answers, the refresh token -// into the operating system's secure store, and one brokered acquisition on +// into the operating system's secure store, and two brokered acquisitions on // top of the session that login established. // +// The second acquisition is the one that measures anything about narrowing. +// Asking for every permission the session carries — which is all this test used +// to do — leaves the broker comparing the issued scopes against an identical +// request, so the check holds no matter what came back and a deployment that +// disregarded the request entirely would still be reported as granted. Asking +// for a strict subset is the request a module actually makes, and it is the +// only form of it that can fail. +// // It is written to pass against both a hosted Asgardeo tenant and a local // Identity Server 7.x from the same code, because the shell makes no // distinction between them and a test that did would stop proving that. @@ -103,19 +112,76 @@ func TestLoginSmoke(t *testing.T) { if err != nil { t.Fatalf("the smoke document selects no context: %v", err) } - broker := &auth.Broker{ - Namespace: smoke.Namespace, - Capabilities: config.Capabilities(), - Selection: selection, - InvocationID: "smoke-login", - StateRoot: stateRoot, + // One broker per acquisition, because the shell allows a module one + // acquisition per command and refuses a second with auth.already_granted. + // Two brokers is therefore not a way around that rule but the accurate + // model of what happens: two commands, run in turn against one session, the + // way a developer uses the shell. + brokerFor := func(invocation string) *auth.Broker { + return &auth.Broker{ + Namespace: smoke.Namespace, + Capabilities: config.Capabilities(), + Selection: selection, + InvocationID: invocation, + StateRoot: stateRoot, + } + } + + // Two acquisitions, because they fail for different reasons and only one of + // them can catch a deployment that disregards a narrowing request. + // + // The first asks for every permission the session already carries. It + // proves the broker can derive access at all, which is what a first run + // against a new deployment needs to know. What it cannot prove is + // narrowing: internal/auth/narrowing.go compares the issued scopes against + // the requested ones, and when the request is the whole session that + // comparison is a set against itself. It holds however the deployment + // behaved. + if !acquire(t, brokerFor("smoke-login-broad"), + auth.Request{Audience: config.Audience, Scopes: config.Scopes}, + "granted", "everything the session carries") { + // The broad request already refused, and the narrow one would refuse + // the same way for the same reason — a deployment that will not issue + // against this audience at all says nothing about narrowing. Running it + // would add a second copy of one finding. + return } - grant, err := broker.Acquire(auth.Request{Audience: config.Audience, Scopes: config.Scopes}) + // The second asks for one permission out of the several the session holds, + // which is the request an actual module makes. Here the verification has + // something it can disagree with: a deployment that answered with the full + // set is caught, and a granted line means the shell watched a real session + // be narrowed rather than assuming it. + // + // Against a deployment that rotates refresh tokens, this second run also + // proves the first one persisted its replacement — it can only reach the + // token endpoint at all with what the first run stored. + target, err := config.NarrowTarget() + if err != nil { + t.Logf("LOGIN SMOKE: narrowing not measured — %v", err) + return + } + acquire(t, brokerFor("smoke-login-narrowed"), + auth.Request{Audience: config.Audience, Scopes: []string{target}}, + "narrowed", "one permission out of the "+strconv.Itoa(len(config.Scopes))+" the session holds") +} + +// acquire runs one brokered acquisition and reports it the way a human reading +// a smoke run needs, returning whether access was granted. +// +// asked describes the request in prose, because the interesting part of a +// verdict line is not the scope list but why that list was chosen. +func acquire(t *testing.T, broker *auth.Broker, request auth.Request, verdict, asked string) bool { + t.Helper() + + grant, err := broker.Acquire(request) switch { case err == nil: - t.Logf("LOGIN SMOKE: granted — access of %d characters bound to %q, expiring %s", - len(grant.Token), config.Audience, grant.ExpiresAt.Format("15:04:05Z07:00")) + t.Logf("LOGIN SMOKE: %s — asked for %s, received access of %d characters bound to %q "+ + "carrying %v, expiring %s", + verdict, asked, len(grant.Token), request.Audience, request.Scopes, + grant.ExpiresAt.Format("15:04:05Z07:00")) + return true case refusalCode(err) == codeNarrowingUnavailable: // Documented, correct behavior. See this test's own doc comment and // docs/guides/login.md's troubleshooting section. @@ -127,10 +193,13 @@ func TestLoginSmoke(t *testing.T) { // The interpolated error text below is what actually says which of the // five happened; this sentence only states what is true regardless: the // shell declined to hand the module more authority than it asked for. - t.Logf("LOGIN SMOKE: refused %s — the shell would not hand the module a grant it could not "+ - "prove was exactly what it asked for. Login and session persistence passed; this refusal "+ - "is the designed outcome, not a failure.\n %v", codeNarrowingUnavailable, err) + t.Logf("LOGIN SMOKE: refused %s — asked for %s, and the shell would not hand the module a "+ + "grant it could not prove was exactly what it asked for. Login and session persistence "+ + "passed; this refusal is the designed outcome, not a failure.\n %v", + codeNarrowingUnavailable, asked, err) + return false default: t.Fatalf("the broker refused for a reason this slice does not accept: %v", err) + return false } } From 883ab3907409f36d13797e79a170dcdda50a2a35 Mon Sep 17 00:00:00 2001 From: Kanushka Gayan Date: Thu, 6 Aug 2026 10:31:54 +0530 Subject: [PATCH 2/2] docs: say which of the five narrowing refusals happened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the smoke run's refusal example truncated. The run prints the shell's own message on an indented second line, and the example stopped before it — while the paragraph beside it said that message is what distinguishes one cause from another. A reader following that advice found nothing to follow it with. The example now carries the indented line, and the four other messages are tabulated beside it. The summary sentence above them is deliberately identical for all five, because auth.narrowing_unavailable covers five distinct causes and a summary naming one would be wrong four times out of five. Only the indented line is specific, which is precisely why it had to be in the example. Two corrections in section 8 of the walkthrough, which the new cross-reference sends readers to and which contradicted what this branch's parent measured. The audience row still said the value to configure is the API resource identifier. That is right on Identity Server and wrong on Asgardeo, where the only value ever placed in `aud` is the client ID — so a reader hitting an audience refusal on Asgardeo was being told to set the one value that cannot work. It now names both products and points at the sections that measured them. The paragraph under the table still said whether Asgardeo narrows would be recorded "once measured against a live tenant". It was measured on 2026-08-06, on both products, and both narrow. Claude-Session: https://claude.ai/code/session_01RojiAgW9hi3b9f9G6ZXBVp --- docs/guides/login.md | 14 ++++++++------ test/smoke/RUNNING.md | 24 ++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/docs/guides/login.md b/docs/guides/login.md index 24764d3..086fb0d 100644 --- a/docs/guides/login.md +++ b/docs/guides/login.md @@ -752,15 +752,17 @@ The message tells you which of five things happened: | "in a form the shell cannot check" | The access token is opaque. | Set the application to issue JWT access tokens (section 2.5). | | "did not state which permissions it issued" | The deployment returned no scope, and the token claims none. | Check the API resource is authorized on the application with the scopes selected. | | "asked for the permissions X and the deployment issued Y" | The deployment ignored the narrower request and issued something else. | The deployment does not narrow on this grant. See below. | -| "is not bound to the ... audience" | The token's `aud` does not carry your audience. | The `audience` in your context document is not the API resource identifier, or the resource is not authorized on the application. | +| "is not bound to the ... audience" | The token's `aud` does not carry your audience. | The `audience` in your context document names something the deployment never puts in `aud`. Which value that is differs by product: the **client ID** on Asgardeo (section 2.5), the **API resource identifier** on Identity Server, and there only once it is in the application's audience list (section 3.5). Failing that, the resource is not authorized on the application. | | "refused to narrow this session" | The token endpoint answered `invalid_scope`. | A scope in your context document is not one the application is authorized for. | The middle case — a deployment that will not narrow — is a property of the -deployment, not something to work around in the shell. Whether Asgardeo narrows -on the refresh grant is -[recorded in the research document](../research/asgardeo-redirect-uri-and-scope-narrowing.md) -once measured against a live tenant. Where it does not narrow, login and session -persistence still work; brokered acquisition refuses, and that refusal is +deployment, not something to work around in the shell. Both supported products +do narrow: measured on 2026-08-06 against a live Asgardeo tenant and against +Identity Server 7.3.0, a session carrying two permissions was refreshed down to +one and answered with exactly that one. Both verdicts are in +[the research document](../research/asgardeo-redirect-uri-and-scope-narrowing.md). +So this row should be rare, and where it does appear, login and session +persistence still work while brokered acquisition refuses — and that refusal is correct. ### `auth.organization_switch_unsupported` diff --git a/test/smoke/RUNNING.md b/test/smoke/RUNNING.md index d1ad0bb..d68e1c1 100644 --- a/test/smoke/RUNNING.md +++ b/test/smoke/RUNNING.md @@ -137,6 +137,9 @@ LOGIN SMOKE: refused auth.narrowing_unavailable — asked for one permission out the 2 the session holds, and the shell would not hand the module a grant it could not prove was exactly what it asked for. Login and session persistence passed; this refusal is the designed outcome, not a failure. + auth_policy: auth.narrowing_unavailable: the "reference" module asked for the + permissions reference:status:read and the deployment issued + reference:status:read, reference:status:write ``` and the run passes. The shell does not hand a module more authority than it @@ -144,8 +147,25 @@ asked for, so refusing is the correct behavior, not a fallback. The walkthrough' troubleshooting section explains what to change in the registration if you want a grant instead. -Read which acquisition refused. On the **narrowed** one it is a statement about -the deployment: it would not issue a token carrying strictly less than the +**The indented line is the one to read.** The sentence above it is the same for +every refusal, because `auth.narrowing_unavailable` covers five distinct causes +and a summary naming one of them would be wrong four times out of five. The +indented line is the shell's own message and says which one happened. The +example above is a deployment that disregarded the request — it answered a +one-permission request with both. The other four read: + +| The indented line says | What happened | +| --- | --- | +| refused to narrow this session | the token endpoint answered `invalid_scope`. Not always the deployment's verdict: an authorization policy the signed-in user does not satisfy answers identically. See the `rejected` narrowing verdict below. | +| in a form the shell cannot check | the access token is opaque, so nothing about it can be proven | +| did not state which permissions it issued | neither the response nor the token named a scope | +| not bound to the *audience* | the token is real but carries a different `aud` — on Asgardeo, almost always because the audience is set to the API resource rather than the client ID | + +Section 8 of [the walkthrough](../../docs/guides/login.md) tabulates the same +five against what to change in the registration. + +Read which acquisition refused, too. On the **narrowed** one it is a statement +about the deployment: it would not issue a token carrying strictly less than the session. On the **broad** one it is almost always the registration instead — most often an audience the deployment never binds — and the run stops there rather than repeating one finding twice.