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/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..d68e1c1 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,17 @@ 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. + 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 @@ -120,6 +147,29 @@ 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. +**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. + ## 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 } }