diff --git a/cmd/authz.go b/cmd/authz.go index 468299b..8ae1617 100644 --- a/cmd/authz.go +++ b/cmd/authz.go @@ -29,6 +29,7 @@ This command has no effect by itself, the authorization type needs to be specifi cmd.AddCommand( authz.UserCmd(imsConfig), authz.UserPkceCmd(imsConfig), + authz.ImplicitCmd(imsConfig), authz.ServiceCmd(imsConfig), authz.JWTCmd(imsConfig), authz.ClientCredentialsCmd(imsConfig), diff --git a/cmd/authz/implicit.go b/cmd/authz/implicit.go new file mode 100644 index 0000000..4737fd0 --- /dev/null +++ b/cmd/authz/implicit.go @@ -0,0 +1,51 @@ +// Copyright 2026 Adobe. All rights reserved. +// This file is licensed 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 REPRESENTATIONS +// OF ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. + +package authz + +import ( + "fmt" + + "github.com/adobe/imscli/ims" + "github.com/spf13/cobra" +) + +func ImplicitCmd(imsConfig *ims.Config) *cobra.Command { + + cmd := &cobra.Command{ + Use: "implicit", + Short: "Negotiate an access token using the OAuth 2.0 implicit grant flow.", + Long: "Perform the 'Implicit Grant Flow' by launching a browser, completing authentication with IMS, " + + "and capturing the access token. IMS redirects to a static page (default: " + + ims.DefaultImplicitRedirectURI + ") that converts the URL fragment to a query string and " + + "forwards it to the local callback server.", + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + resp, err := imsConfig.AuthorizeImplicit() + if err != nil { + return fmt.Errorf("error in implicit authorization: %w", err) + } + fmt.Println(resp) + return nil + }, + } + + cmd.Flags().StringVarP(&imsConfig.ClientID, "clientID", "c", "", "IMS client ID.") + cmd.Flags().StringSliceVarP(&imsConfig.Scopes, "scopes", "s", []string{}, "Scopes to request.") + cmd.Flags().IntVarP(&imsConfig.Port, "port", "l", 8888, "Local port to be used by the OAuth Client. "+ + "Must match the port that the redirector page sends the browser to (the default redirector pins 8888).") + cmd.Flags().StringVar(&imsConfig.RedirectURI, "redirectURI", ims.DefaultImplicitRedirectURI, + "Redirect URI registered with IMS.") + cmd.Flags().StringSliceVarP(&imsConfig.Resource, "resource", "r", nil, + "Resource indicator URI(s) for audience-restricted tokens.") + + return cmd +} diff --git a/docs/redirect/implicit/index.html b/docs/redirect/implicit/index.html new file mode 100644 index 0000000..3b1f913 --- /dev/null +++ b/docs/redirect/implicit/index.html @@ -0,0 +1,17 @@ + + +
+ +#).State mismatch — see terminal.
`) + return + } + + if e := q.Get("error"); e != "" { + h.errCh <- fmt.Errorf("authorization error: %s: %s", e, q.Get("error_description")) + writeCallbackHTML(w, `See terminal.
`) + return + } + + token := q.Get("access_token") + if token == "" { + h.errCh <- fmt.Errorf("missing access_token in callback") + writeCallbackHTML(w, `No token in response.
`) + return + } + + h.resCh <- &TokenInfo{AccessToken: token} + writeCallbackHTML(w, `You can close this tab.
`) +} + +func writeCallbackHTML(w http.ResponseWriter, body string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, body) +} + +// randomState generates a cryptographically random state parameter for the +// authorize request. Mirrors github.com/adobe/ims-go/login/server.go. +func randomState() (string, error) { + b := make([]byte, 128) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate random state: %w", err) + } + return base64.StdEncoding.EncodeToString(b), nil +} diff --git a/ims/authz_implicit_test.go b/ims/authz_implicit_test.go new file mode 100644 index 0000000..568246a --- /dev/null +++ b/ims/authz_implicit_test.go @@ -0,0 +1,175 @@ +// Copyright 2026 Adobe. All rights reserved. +// This file is licensed 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 REPRESENTATIONS +// OF ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. + +package ims + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestValidateAuthorizeImplicitConfig(t *testing.T) { + tests := []struct { + name string + config Config + wantErr string + }{ + { + name: "valid", + config: Config{URL: "https://ims.example.com", ClientID: "c", Scopes: []string{"openid"}, Port: 8888, RedirectURI: DefaultImplicitRedirectURI}, + wantErr: "", + }, + { + name: "missing URL", + config: Config{ClientID: "c", Scopes: []string{"openid"}, Port: 8888, RedirectURI: DefaultImplicitRedirectURI}, + wantErr: "missing IMS base URL", + }, + { + name: "invalid URL", + config: Config{URL: "://bad", ClientID: "c", Scopes: []string{"openid"}, Port: 8888, RedirectURI: DefaultImplicitRedirectURI}, + wantErr: "unable to parse URL", + }, + { + name: "missing scopes", + config: Config{URL: "https://ims.example.com", ClientID: "c", Port: 8888, RedirectURI: DefaultImplicitRedirectURI}, + wantErr: "missing scopes", + }, + { + name: "empty scope", + config: Config{URL: "https://ims.example.com", ClientID: "c", Scopes: []string{""}, Port: 8888, RedirectURI: DefaultImplicitRedirectURI}, + wantErr: "missing scopes", + }, + { + name: "missing clientID", + config: Config{URL: "https://ims.example.com", Scopes: []string{"openid"}, Port: 8888, RedirectURI: DefaultImplicitRedirectURI}, + wantErr: "missing client id", + }, + { + name: "missing port", + config: Config{URL: "https://ims.example.com", ClientID: "c", Scopes: []string{"openid"}, RedirectURI: DefaultImplicitRedirectURI}, + wantErr: "missing or invalid port", + }, + { + name: "negative port", + config: Config{URL: "https://ims.example.com", ClientID: "c", Scopes: []string{"openid"}, Port: -1, RedirectURI: DefaultImplicitRedirectURI}, + wantErr: "missing or invalid port", + }, + { + name: "missing redirect URI", + config: Config{URL: "https://ims.example.com", ClientID: "c", Scopes: []string{"openid"}, Port: 8888}, + wantErr: "missing redirect URI", + }, + { + name: "invalid redirect URI", + config: Config{URL: "https://ims.example.com", ClientID: "c", Scopes: []string{"openid"}, Port: 8888, RedirectURI: "://bad"}, + wantErr: "unable to parse redirect URI", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.validateAuthorizeImplicitConfig() + assertError(t, err, tt.wantErr) + }) + } +} + +func TestImplicitCapture(t *testing.T) { + const state = "expected-state-abc" + + tests := []struct { + name string + url string + wantToken string + wantErr string + }{ + { + name: "happy path", + url: "/?access_token=tok&state=" + state + "&expires_in=3600&token_type=bearer", + wantToken: "tok", + }, + { + name: "state mismatch", + url: "/?access_token=tok&state=attacker", + wantErr: "state mismatch", + }, + { + name: "missing state", + url: "/?access_token=tok", + wantErr: "state mismatch", + }, + { + name: "IMS error param", + url: "/?error=access_denied&error_description=user+rejected&state=" + state, + wantErr: "authorization error: access_denied: user rejected", + }, + { + name: "missing token", + url: "/?state=" + state, + wantErr: "missing access_token", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resCh := make(chan *TokenInfo, 1) + errCh := make(chan error, 1) + h := &implicitHandler{ + expectedState: state, + resCh: resCh, + errCh: errCh, + } + + req := httptest.NewRequest(http.MethodGet, tt.url, nil) + rec := httptest.NewRecorder() + h.capture(rec, req) + + select { + case res := <-resCh: + if tt.wantErr != "" { + t.Fatalf("expected error %q, got token %q", tt.wantErr, res.AccessToken) + } + if res.AccessToken != tt.wantToken { + t.Errorf("token: got %q, want %q", res.AccessToken, tt.wantToken) + } + case err := <-errCh: + if tt.wantErr == "" { + t.Fatalf("expected token, got error %v", err) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error: got %q, want it to contain %q", err.Error(), tt.wantErr) + } + default: + t.Fatalf("handler neither sent token nor error") + } + + if got := rec.Result().Header.Get("Content-Type"); !strings.HasPrefix(got, "text/html") { + t.Errorf("Content-Type: got %q, want text/html prefix", got) + } + }) + } +} + +func TestRandomStateProducesDistinctValues(t *testing.T) { + a, err := randomState() + if err != nil { + t.Fatalf("randomState: %v", err) + } + b, err := randomState() + if err != nil { + t.Fatalf("randomState: %v", err) + } + if a == b { + t.Errorf("randomState produced identical values across calls") + } + if len(a) < 40 { + t.Errorf("randomState too short: %d chars", len(a)) + } +} diff --git a/ims/authz_user.go b/ims/authz_user.go index 7a85893..3418cfc 100644 --- a/ims/authz_user.go +++ b/ims/authz_user.go @@ -21,7 +21,6 @@ import ( "github.com/adobe/ims-go/ims" "github.com/adobe/ims-go/login" - "github.com/pkg/browser" ) const ( @@ -120,17 +119,7 @@ func (i Config) authorizeUser(pkce bool) (string, error) { localUrl := fmt.Sprintf("http://localhost:%d/", i.Port) - // Suppress "Opening in existing browser session." messages from chromium-based - // browsers. The CLI token output goes to stdout, so stray browser messages - // would corrupt piped/scripted output. Save and restore to avoid permanent - // mutation of the package-level variable. - origStdout := browser.Stdout - browser.Stdout = nil - err = browser.OpenURL(localUrl) - browser.Stdout = origStdout - if err != nil { - fmt.Fprintf(os.Stderr, "error launching the browser, open it and visit %s\n", localUrl) - } + openBrowser(localUrl) // Capture Serve errors via a buffered channel. Buffered so the goroutine // can always write and exit, even if nobody reads (e.g., a response arrived diff --git a/ims/browser.go b/ims/browser.go new file mode 100644 index 0000000..e3623bd --- /dev/null +++ b/ims/browser.go @@ -0,0 +1,34 @@ +// Copyright 2026 Adobe. All rights reserved. +// This file is licensed 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 REPRESENTATIONS +// OF ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. + +package ims + +import ( + "fmt" + "os" + + "github.com/pkg/browser" +) + +// openBrowser opens the given URL in the system default browser. Temporarily +// mutes browser.Stdout to suppress the "Opening in existing browser session" +// messages that some chromium-based browsers emit; the CLI's token output goes +// to stdout, so stray browser chatter would corrupt piped or scripted output. +// On failure, prints a fallback instruction to stderr and returns — callers +// continue (the user can open the URL manually). +func openBrowser(url string) { + origStdout := browser.Stdout + browser.Stdout = nil + err := browser.OpenURL(url) + browser.Stdout = origStdout + if err != nil { + fmt.Fprintf(os.Stderr, "error launching the browser, open it and visit %s\n", url) + } +} diff --git a/ims/config.go b/ims/config.go index 529daf8..36f09b8 100644 --- a/ims/config.go +++ b/ims/config.go @@ -54,6 +54,7 @@ type Config struct { DecodeFulfillableData bool ClientName string RedirectURIs []string + RedirectURI string Resource []string }