Skip to content
Open
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
36 changes: 34 additions & 2 deletions cli/pkg/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,18 @@ package auth

import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"

"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"

"github.com/wso2/agent-manager/cli/pkg/browser"
"github.com/wso2/agent-manager/cli/pkg/clients"
"github.com/wso2/agent-manager/cli/pkg/clierr"
"github.com/wso2/agent-manager/cli/pkg/config"
"github.com/wso2/agent-manager/cli/pkg/iostreams"
)
Expand Down Expand Up @@ -74,7 +78,7 @@ func loginClientCredentials(ctx context.Context, opts LoginOptions) (*config.Ins
}
tok, err := cc.Token(ctx)
if err != nil {
return nil, fmt.Errorf("client_credentials token exchange: %w", err)
return nil, wrapTokenError(err, "client_credentials token exchange")
}

return &config.Instance{
Expand All @@ -92,6 +96,31 @@ func loginClientCredentials(ctx context.Context, opts LoginOptions) (*config.Ins
}, nil
}

// classifyTokenError maps oauth2 token-endpoint errors to typed CLIErrors.
// Returns nil for errors that don't have a known classification.
func classifyTokenError(err error) error {
var re *oauth2.RetrieveError
if !errors.As(err, &re) {
return nil
}
if re.Response != nil && re.Response.StatusCode == http.StatusUnauthorized {
return clierr.New(clierr.Unauthorized, "token endpoint rejected the request (401)")
}
if re.ErrorCode == "invalid_grant" {
return clierr.Newf(clierr.Unauthorized, "invalid_grant: %s", re.ErrorDescription)
}
return nil
}

// wrapTokenError classifies known token-endpoint errors into typed CLIErrors
// and falls back to a fmt.Errorf wrap with the given context.
func wrapTokenError(err error, fallbackContext string) error {
if ce := classifyTokenError(err); ce != nil {
return ce
}
return fmt.Errorf("%s: %w", fallbackContext, err)
}

func loginPKCE(ctx context.Context, opts LoginOptions) (*config.Instance, error) {
clientID := opts.ClientID
if clientID == "" {
Expand Down Expand Up @@ -131,7 +160,10 @@ func loginPKCE(ctx context.Context, opts LoginOptions) (*config.Instance, error)

tok, err := authCodePKCE(ctx, oauthCfg, opts.IO, openBrowser)
if err != nil {
return nil, fmt.Errorf("authorization code exchange: %w", err)
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, io.EOF) {
return nil, clierr.New(clierr.AuthLoginCancelled, "browser login cancelled")
}
return nil, wrapTokenError(err, "authorization code exchange")
}

return &config.Instance{
Expand Down
117 changes: 117 additions & 0 deletions cli/pkg/auth/login_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
//
// WSO2 LLC. licenses this file to you under the Apache License,
// Version 2.0 (the "License"); you may not use this file except
// in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

package auth

import (
"errors"
"fmt"
"net/http"
"strings"
"testing"

"golang.org/x/oauth2"

"github.com/wso2/agent-manager/cli/pkg/clierr"
)

func TestClassifyTokenError(t *testing.T) {
cases := []struct {
name string
err error
wantCode string // empty means expect nil return
wantMsg string // substring expected in message
}{
{
name: "401 from token endpoint",
err: &oauth2.RetrieveError{Response: &http.Response{StatusCode: http.StatusUnauthorized}},
wantCode: clierr.Unauthorized,
wantMsg: "401",
},
{
name: "invalid_grant with description",
err: &oauth2.RetrieveError{
Response: &http.Response{StatusCode: http.StatusBadRequest},
ErrorCode: "invalid_grant",
ErrorDescription: "token has expired",
},
wantCode: clierr.Unauthorized,
wantMsg: "invalid_grant",
},
{
name: "invalid_grant on 400 response",
err: &oauth2.RetrieveError{
Response: &http.Response{StatusCode: http.StatusBadRequest},
ErrorCode: "invalid_grant",
},
wantCode: clierr.Unauthorized,
},
{
name: "403 forbidden — not classified",
err: &oauth2.RetrieveError{Response: &http.Response{StatusCode: http.StatusForbidden}},
wantCode: "",
},
{
name: "other oauth error code — not classified",
err: &oauth2.RetrieveError{Response: &http.Response{StatusCode: http.StatusBadRequest}, ErrorCode: "unsupported_grant_type"},
wantCode: "",
},
{
name: "plain error — not classified",
err: errors.New("connection refused"),
wantCode: "",
},
{
name: "nil response on RetrieveError — not classified",
err: &oauth2.RetrieveError{Response: nil, ErrorCode: ""},
wantCode: "",
},
{
name: "wrapped RetrieveError with 401 — still classified",
err: fmt.Errorf("outer wrapper: %w", &oauth2.RetrieveError{Response: &http.Response{StatusCode: http.StatusUnauthorized}}),
wantCode: clierr.Unauthorized,
wantMsg: "401",
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := classifyTokenError(tc.err)

if tc.wantCode == "" {
if got != nil {
t.Errorf("expected nil, got %v", got)
}
return
}

if got == nil {
t.Fatalf("expected classified error with code %q, got nil", tc.wantCode)
}

var ce clierr.CLIError
if !errors.As(got, &ce) {
t.Fatalf("returned error is not a clierr.CLIError: %T", got)
}
if ce.Code != tc.wantCode {
t.Errorf("code = %q, want %q", ce.Code, tc.wantCode)
}
if tc.wantMsg != "" && !strings.Contains(ce.Message, tc.wantMsg) {
t.Errorf("message = %q, want to contain %q", ce.Message, tc.wantMsg)
}
})
}
}
1 change: 1 addition & 0 deletions cli/pkg/clierr/clierr.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const (
SkillInstallFailed = "SKILL_INSTALL_FAILED"
SkillRemoveFailed = "SKILL_REMOVE_FAILED"
SkillListFailed = "SKILL_LIST_FAILED"
AuthLoginCancelled = "AUTH_LOGIN_CANCELLED"
)

// CLIError is both the wire body of an error envelope and a Go error value.
Expand Down
2 changes: 1 addition & 1 deletion cli/pkg/cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func runLogin(ctx context.Context, opts *LoginOptions) error {
OpenBrowser: opts.OpenBrowser,
})
if err != nil {
return render.Error(opts.IO, scope, clierr.Newf(clierr.Transport, "%v", err))
return render.Error(opts.IO, scope, err)
}

cfg, err := opts.Config()
Expand Down
98 changes: 98 additions & 0 deletions cli/pkg/cmd/login_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
//
// WSO2 LLC. licenses this file to you under the Apache License,
// Version 2.0 (the "License"); you may not use this file except
// in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

package cmd

import (
"context"
"encoding/json"
"errors"
"testing"

"github.com/wso2/agent-manager/cli/pkg/auth"
"github.com/wso2/agent-manager/cli/pkg/clierr"
"github.com/wso2/agent-manager/cli/pkg/config"
"github.com/wso2/agent-manager/cli/pkg/iostreams"
)

func TestRunLogin(t *testing.T) {
cases := []struct {
name string
url string
authErr error
wantErrCode string
}{
{
name: "typed CLIError passes through unchanged",
url: "https://example.com",
authErr: clierr.New(clierr.Unauthorized, "client credentials rejected (401)"),
wantErrCode: clierr.Unauthorized,
},
{
name: "plain error becomes Transport",
url: "https://example.com",
authErr: errors.New("dial tcp: connection refused"),
wantErrCode: clierr.Transport,
},
{
name: "AuthLoginCancelled passes through unchanged",
url: "https://example.com",
authErr: clierr.New(clierr.AuthLoginCancelled, "browser login cancelled"),
wantErrCode: clierr.AuthLoginCancelled,
},
{
name: "missing --url returns InvalidFlag without calling Authenticate",
url: "",
authErr: nil,
wantErrCode: clierr.InvalidFlag,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
io, _, out, _ := iostreams.Test()
io.JSON = true

opts := &LoginOptions{
IO: io,
URL: tc.url,
Authenticate: func(_ context.Context, _ auth.LoginOptions) (*config.Instance, error) {
if tc.url == "" {
t.Fatal("Authenticate should not be called when --url is missing")
}
return nil, tc.authErr
},
}

err := runLogin(context.Background(), opts)
if err == nil {
t.Fatal("expected error, got nil")
}

var env map[string]any
if jerr := json.Unmarshal(out.Bytes(), &env); jerr != nil {
t.Fatalf("decode envelope: %v\nbody=%q", jerr, out.String())
}
errBody, ok := env["error"].(map[string]any)
if !ok {
t.Fatalf("envelope missing 'error' field: %v", env)
}
if got := errBody["code"]; got != tc.wantErrCode {
t.Errorf("code = %q, want %q", got, tc.wantErrCode)
}
})
}
}
Loading