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
8 changes: 8 additions & 0 deletions internal/app/invoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ func (s Shell) invokeModule(namespace string, resolved modules.Resolved, args []
if err != nil {
return err
}
// The state root reaches the broker for the session rotation lock alone.
// No session material is written under it; the OS secure store holds all
// of that.
root, err := s.stateRoot()
if err != nil {
return err
}
// The invocation is minted here, before anything is launched, so the
// broker and the module session bind access to the same command.
invocationID, err := rpc.NewInvocationID()
Expand All @@ -73,6 +80,7 @@ func (s Shell) invokeModule(namespace string, resolved modules.Resolved, args []
Capabilities: resolved.Receipt.Capabilities,
Selection: selection,
InvocationID: invocationID,
StateRoot: root,
},
}
outcome, invokeErr := launcher.Invoke(context.Background(), rpc.Invocation{
Expand Down
86 changes: 36 additions & 50 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,14 @@
package auth

import (
"errors"
"fmt"
"net/http"
"os"
"slices"
"strings"
"time"

"github.com/wso2/wso2-cli/internal/auth/devtoken"
"github.com/wso2/wso2-cli/internal/contexts"
"github.com/wso2/wso2-cli/internal/modules"
"github.com/wso2/wso2-cli/sdk/problem"
Expand Down Expand Up @@ -112,6 +113,13 @@ type Broker struct {
// Credentials reads a named environment variable. It defaults to the
// process environment, and a test replaces it.
Credentials func(name string) (string, bool)
// StateRoot is the shell-owned state root. It hosts the advisory locks
// that keep refresh-token rotation single-writer; no session material is
// ever written under it.
StateRoot string
// HTTPClient serves issuer traffic. It defaults to http.DefaultClient,
// and a test points it at an in-process issuer.
HTTPClient *http.Client
// Now reads the current time. It defaults to time.Now.
Now func() time.Time

Expand All @@ -125,12 +133,6 @@ type Broker struct {
// Every refusal is a typed problem in the authentication class, with recovery
// guidance a user can act on and no detail of the credential behind it.
func (b *Broker) Acquire(request Request) (Grant, error) {
if b.Namespace != ProofNamespace {
return Grant{}, denial("auth.namespace_not_brokered",
fmt.Sprintf("the %q module asked for access, and this shell brokers access for the "+
"non-production %q proof only", b.namespace(), ProofNamespace),
"Install a module the WSO2 CLI can authenticate, or run the command without it.")
}
if b.granted {
return Grant{}, denial("auth.already_granted",
fmt.Sprintf("the %q module asked for access twice in one command", b.namespace()),
Expand All @@ -139,32 +141,19 @@ func (b *Broker) Acquire(request Request) (Grant, error) {
if err := b.checkDeclared(request); err != nil {
return Grant{}, err
}
if err := b.checkContext(); err != nil {
return Grant{}, err
}

credential, err := b.credential()
// The receipt is checked before the identity is, so a module asking beyond
// its installation is told so whatever context happens to be selected.
resolved, err := b.resolveSource(request)
if err != nil {
return Grant{}, err
return Grant{}, asDenial(err)
}

now := b.now()
token, mintErr := devtoken.Mint(credential, devtoken.Claims{
Audience: request.Audience,
Scopes: request.Scopes,
Organization: b.Selection.Context.Organization,
Invocation: b.InvocationID,
}, now)
if mintErr != nil {
// The issuer's own error may name what it was given, so it is not
// carried into a problem the shell renders.
return Grant{}, denial("auth.access_not_issued",
fmt.Sprintf("the shell could not issue access for the %q module", b.namespace()),
"Retry the command. Report the failure if it persists.")
grant, err := resolved.mint(request, b.now())
if err != nil {
return Grant{}, asDenial(err)
}

b.granted = true
return Grant{Token: token, ExpiresAt: now.Add(devtoken.Lifetime).UTC()}, nil
return grant, nil
}

// checkDeclared intersects the request with the module receipt.
Expand All @@ -189,28 +178,6 @@ func (b *Broker) checkDeclared(request Request) error {
return nil
}

// checkContext proves the selected context can be authenticated against at all.
func (b *Broker) checkContext() error {
if b.Selection.Identity.Auth.Kind == "" && b.Selection.Identity.Auth.CredentialVariable == "" {
return denial("auth.context_not_selected",
fmt.Sprintf("the %q module needs access, and no WSO2 CLI context is selected", b.namespace()),
"Select a context that names the organization and credential source to use.")
}
if b.Selection.Identity.Auth.Kind != contexts.MethodDevelopmentCredential {
return denial("auth.method_unsupported",
fmt.Sprintf("the %q context uses an authentication method this shell does not implement",
b.Selection.Context.Name),
fmt.Sprintf("Select a context whose authentication method is %q.",
contexts.MethodDevelopmentCredential))
}
if b.Selection.Context.Organization == "" {
return denial("auth.organization_not_selected",
fmt.Sprintf("the %q context names no organization to act within", b.Selection.Context.Name),
"Select a context that names the organization the command targets.")
}
return nil
}

// credential reads the source credential the context names.
//
// The value stays in this process: it is the issuer's signing key and is never
Expand Down Expand Up @@ -254,6 +221,25 @@ func (b *Broker) namespace() string {
return b.Namespace
}

// asDenial restates any typed problem a source raised as a broker denial.
//
// A source may borrow a problem from a package that knows nothing about this
// broker — the session store's own auth.login_required, for one. Everything
// Acquire refuses with is a Denial, so one type answers for every refusal and
// the shell has a single place to decide what a module is told versus what the
// user is told.
func asDenial(err error) error {
var refusal Denial
if errors.As(err, &refusal) {
return refusal
}
var typed problem.Problem
if errors.As(err, &typed) {
return Denial{Problem: typed}
}
return err
}

// denial reports a broker refusal the module and the user can both be told in
// full. Every refusal is in the authentication class, so automation can tell an
// access failure from a product failure by exit code alone.
Expand Down
182 changes: 182 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import (
"testing"
"time"

keyring "github.com/zalando/go-keyring"

"github.com/wso2/wso2-cli/internal/auth"
"github.com/wso2/wso2-cli/internal/auth/devtoken"
"github.com/wso2/wso2-cli/internal/contexts"
Expand All @@ -37,6 +39,7 @@ const (
readScope = "reference:status:read"
organization = "reference-org"
invocationID = "invocation-7f2a"
homeTenant = organization
)

var acquiredAt = time.Date(2026, time.July, 27, 10, 0, 0, 0, time.UTC)
Expand Down Expand Up @@ -255,6 +258,185 @@ func TestNoDenialRevealsTheSourceCredential(t *testing.T) {
}
}

// productionBroker builds a broker over a schema version 2 identity of the
// given kind, configured so every production policy check passes. Each test
// breaks exactly one of them, so a refusal names the check it broke.
func productionBroker(t *testing.T, kind string) *auth.Broker {
t.Helper()
return &auth.Broker{
Namespace: "reference",
Capabilities: modules.Capabilities{AuthAudiences: []string{audience}, AuthScopes: []string{readScope}},
Selection: contexts.Selection{
Context: contexts.Context{
Name: "reference-cloud",
Identity: "reference-cloud",
Organization: homeTenant,
},
Identity: contexts.Identity{
Name: "reference-cloud",
Type: "cloud",
Auth: contexts.IdentityAuth{
Kind: kind,
Issuer: "https://issuer.example.test",
ClientID: "wso2cli",
Tenant: homeTenant,
CredentialRef: "reference-cloud",
},
Products: map[string]contexts.Product{
"reference": {
Endpoint: "https://reference.example.test",
Audience: audience,
Scopes: []string{readScope},
},
},
},
},
InvocationID: invocationID,
// A production identity derives access under a session lock, and a
// broker with no state root would take that lock relative to whatever
// directory the test happens to run in — inside the source tree.
StateRoot: t.TempDir(),
Now: func() time.Time { return acquiredAt },
}
}

// withProduct replaces the reference product registration, which a table
// literal cannot assign through a map member.
func withProduct(broker *auth.Broker, product contexts.Product) {
broker.Selection.Identity.Products["reference"] = product
}

func TestTheIdentityKindDecidesWhichPolicyTheBrokerApplies(t *testing.T) {
// One switch answers "what kind of identity is this?", and every refusal
// below is reached through it. The order matters as much as the codes: an
// identity that configures no product is refused for the product it does
// not configure, never for the organization it happens to name.
for name, testcase := range map[string]struct {
kind string
mutate func(*auth.Broker)
code string
}{
"no identity is no selection": {
kind: "",
code: "auth.context_not_selected",
},
"a device login is legal but unimplemented": {
kind: contexts.KindOAuthDevice,
code: "auth.kind_not_implemented",
},
"a personal access token is legal but unimplemented": {
kind: contexts.KindPAT,
code: "auth.kind_not_implemented",
},
"an unreadable kind is unsupported": {
kind: "browser-pkce",
code: "auth.method_unsupported",
},
"a browser identity that configures no product": {
kind: contexts.KindOAuthBrowser,
mutate: func(b *auth.Broker) { b.Selection.Identity.Products = nil },
code: "auth.product_not_configured",
},
"a browser identity registered for another audience": {
kind: contexts.KindOAuthBrowser,
mutate: func(b *auth.Broker) {
withProduct(b, contexts.Product{
Endpoint: "https://reference.example.test",
Audience: "other-api",
Scopes: []string{readScope},
})
},
code: "auth.product_not_configured",
},
"a browser identity whose product does not carry the scope": {
kind: contexts.KindOAuthBrowser,
mutate: func(b *auth.Broker) {
withProduct(b, contexts.Product{
Endpoint: "https://reference.example.test",
Audience: audience,
Scopes: []string{"reference:status:write"},
})
},
code: "auth.product_not_configured",
},
"a browser identity asked to act outside its home tenant": {
kind: contexts.KindOAuthBrowser,
mutate: func(b *auth.Broker) { b.Selection.Context.Organization = "another-org" },
code: "auth.organization_switch_unsupported",
},
"a client-credentials identity that configures no product": {
kind: contexts.KindClientCredentials,
mutate: func(b *auth.Broker) { b.Selection.Identity.Products = nil },
code: "auth.product_not_configured",
},
"a client-credentials identity asked to act outside its home tenant": {
kind: contexts.KindClientCredentials,
mutate: func(b *auth.Broker) { b.Selection.Context.Organization = "another-org" },
code: "auth.organization_switch_unsupported",
},
} {
t.Run(name, func(t *testing.T) {
broker := productionBroker(t, testcase.kind)
if testcase.mutate != nil {
testcase.mutate(broker)
}

refusal := denied(t, broker, declaredRequest())

if refusal.Problem.Code != testcase.code {
t.Errorf("code = %q, want %q", refusal.Problem.Code, testcase.code)
}
})
}
}

func TestAFullyConfiguredProductionIdentityIsAdmittedByPolicy(t *testing.T) {
// Policy admits this request, so only the token source can refuse it now.
// Separating the two is what the source seam is for, and this pins the
// hand-off exactly: with nothing stored to derive from, what comes back is
// the source asking for a login, not policy turning the identity away.
keyring.MockInit()
broker := productionBroker(t, contexts.KindOAuthBrowser)

refusal := denied(t, broker, declaredRequest())

if refusal.Problem.Code != "auth.login_required" {
t.Errorf("code = %q, want auth.login_required", refusal.Problem.Code)
}
}

func TestAnUnselectedContextIsRefusedBeforeTheProofNamespaceGuard(t *testing.T) {
// Ordering. The proof-namespace guard belongs to the development source, so
// reaching it means an identity was resolved first. A namespace outside the
// proof with no identity at all is told what is actually wrong — nothing is
// selected — rather than that its namespace is not brokered.
broker := broker(t)
broker.Namespace = "api"
broker.Selection = contexts.Selection{Context: contexts.Context{Name: contexts.DefaultName}}

refusal := denied(t, broker, declaredRequest())

if refusal.Problem.Code != "auth.context_not_selected" {
t.Errorf("code = %q, want auth.context_not_selected", refusal.Problem.Code)
}
}

func TestAProductNamespaceTheIdentityDoesNotConfigureIsRefused(t *testing.T) {
// A production identity reaching the broker for a namespace it does not
// register is told so, rather than being handed another product's audience.
broker := productionBroker(t, contexts.KindOAuthBrowser)
broker.Namespace = "api"

refusal := denied(t, broker, declaredRequest())

if refusal.Problem.Code != "auth.product_not_configured" {
t.Errorf("code = %q, want auth.product_not_configured", refusal.Problem.Code)
}
if !strings.Contains(refusal.Problem.Message, "api") {
t.Errorf("the refusal %q does not name the product namespace", refusal.Problem.Message)
}
}

// denied runs one request that must be refused and returns the shell's denial.
func denied(t *testing.T, broker *auth.Broker, request auth.Request) auth.Denial {
t.Helper()
Expand Down
Loading