Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions docs/guides/login.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
14 changes: 9 additions & 5 deletions docs/plans/login-first-slice.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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.
Expand Down
62 changes: 56 additions & 6 deletions test/smoke/RUNNING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -107,19 +129,47 @@ 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
asked for, so refusing is the correct behavior, not a fallback. The walkthrough's
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
Expand Down
95 changes: 82 additions & 13 deletions test/smoke/login_smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"io"
"os"
"path/filepath"
"strconv"
"testing"

"github.com/wso2/wso2-cli/internal/app"
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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
}
}