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 @@ + + + + + imscli implicit-flow redirection to localhost:8888 + + +

If your browser is not automatically redirected, copy the URL from your address bar — the access token is in the fragment (after the #).

+ + + + diff --git a/ims/authz_implicit.go b/ims/authz_implicit.go new file mode 100644 index 0000000..58e48cf --- /dev/null +++ b/ims/authz_implicit.go @@ -0,0 +1,241 @@ +// 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 ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "time" + + "github.com/adobe/ims-go/ims" +) + +// DefaultImplicitRedirectURI is the canonical public redirector served from +// docs/redirect/implicit/index.html. The implicit flow returns the access +// token in the URL fragment (#access_token=...), which the browser never +// sends to a server. That static page runs a one-liner that converts the +// fragment to a query string and navigates the browser to localhost, where +// our Go handler can finally read the parameters from r.URL.Query(). +// +// Crafty users running on a non-default port can host their own redirector +// (pointing at the desired localhost port), register it with IMS for their +// client, and pass --redirectURI to override this default. +const DefaultImplicitRedirectURI = "https://opensource.adobe.com/imscli/redirect/implicit/" + +// validateAuthorizeImplicitConfig checks that the configuration has valid +// parameters for the implicit grant flow. +func (i Config) validateAuthorizeImplicitConfig() error { + switch { + case i.URL == "": + return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("unable to parse URL parameter") + case len(i.Scopes) == 0 || i.Scopes[0] == "": + return fmt.Errorf("missing scopes parameter") + case i.ClientID == "": + return fmt.Errorf("missing client id parameter") + case i.Port <= 0: + return fmt.Errorf("missing or invalid port parameter") + case i.RedirectURI == "": + return fmt.Errorf("missing redirect URI parameter") + case !validateURL(i.RedirectURI): + return fmt.Errorf("unable to parse redirect URI parameter") + } + log.Println("all needed parameters verified not empty") + return nil +} + +// AuthorizeImplicit performs the OAuth 2.0 implicit grant flow with IMS. +// IMS redirects the browser to the configured RedirectURI (a public static +// page) which JS-rewrites the URL fragment into a query string and navigates +// the browser to the local listener. Returns the access token after state +// validation. +func (i Config) AuthorizeImplicit() (string, error) { + if err := i.validateAuthorizeImplicitConfig(); err != nil { + return "", fmt.Errorf("invalid parameters for implicit authorization: %w", err) + } + + c, err := i.newIMSClient() + if err != nil { + return "", fmt.Errorf("error creating the IMS client: %w", err) + } + + state, err := randomState() + if err != nil { + return "", fmt.Errorf("generate state: %w", err) + } + + authURL, err := c.AuthorizeURL(&ims.AuthorizeURLConfig{ + ClientID: i.ClientID, + GrantType: ims.GrantTypeImplicit, + Scope: i.Scopes, + RedirectURI: i.RedirectURI, + State: state, + Resource: i.Resource, + }) + if err != nil { + return "", fmt.Errorf("build authorize URL: %w", err) + } + + srv, err := startCaptureServer(state, i.Port) + if err != nil { + return "", err + } + log.Println("Local server successfully launched and contacted.") + + openBrowser(authURL) + + var ( + serr error + resp *TokenInfo + ) + + select { + case serr = <-srv.errCh: + log.Println("The implicit callback handler returned an error message.") + case resp = <-srv.resCh: + log.Println("The implicit callback handler returned a token.") + case serr = <-srv.serveCh: + log.Println("The local server stopped unexpectedly.") + case <-time.After(authTimeout): + fmt.Fprintf(os.Stderr, "Timeout reached waiting for the user to finish the authentication ...\n") + serr = fmt.Errorf("user timed out") + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + + if err := srv.server.Shutdown(shutdownCtx); err != nil { + return "", fmt.Errorf("error shutting down the local server: %w", err) + } + log.Println("Local server shut down ...") + + if serr != nil { + return "", fmt.Errorf("error in implicit authorization: %w", serr) + } + + return resp.AccessToken, nil +} + +// captureServer is the local HTTP listener that receives the access token +// from the static redirector page after the JS bridge rewrites the URL +// fragment into a query string. +type captureServer struct { + server *http.Server + resCh <-chan *TokenInfo + errCh <-chan error + serveCh <-chan error +} + +// startCaptureServer creates and starts the local capture HTTP server in a +// background goroutine, listening on the given port. The returned channels +// signal the outcome: resCh on success, errCh on handler-level errors, +// serveCh if the server itself stops unexpectedly. The caller is responsible +// for calling Shutdown on the server when done; that also closes the listener +// via http.Server.Serve's unwind. +func startCaptureServer(state string, port int) (*captureServer, error) { + resCh := make(chan *TokenInfo, 1) + errCh := make(chan error, 1) + + handler := &implicitHandler{ + expectedState: state, + resCh: resCh, + errCh: errCh, + } + + mux := http.NewServeMux() + mux.HandleFunc("/", handler.capture) + + server := &http.Server{Handler: mux} + + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return nil, fmt.Errorf("unable to listen at port %d: %w", port, err) + } + + // Capture Serve errors via a buffered channel. See ims/authz_user.go:136-140 + // for the rationale. + serveCh := make(chan error, 1) + go func() { + serveCh <- server.Serve(listener) + }() + + return &captureServer{ + server: server, + resCh: resCh, + errCh: errCh, + serveCh: serveCh, + }, nil +} + +// implicitHandler holds the per-request state for the implicit-flow callback. +// Extracted to a struct so the route handler can be unit-tested in isolation +// from net.Listen and the live select loop. +type implicitHandler struct { + expectedState string + resCh chan<- *TokenInfo + errCh chan<- error +} + +// capture reads the access token (and related params) from the query string +// that the external redirector page rewrote from the URL fragment. Validates +// state against the value sent on the original authorize request — protects +// the local callback from CSRF (any other open browser tab could otherwise +// navigate here with attacker-supplied parameters). +func (h *implicitHandler) capture(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + got := q.Get("state") + if subtle.ConstantTimeCompare([]byte(got), []byte(h.expectedState)) != 1 { + h.errCh <- fmt.Errorf("state mismatch") + writeCallbackHTML(w, `

Login failed

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, `

Login failed

See terminal.

`) + return + } + + token := q.Get("access_token") + if token == "" { + h.errCh <- fmt.Errorf("missing access_token in callback") + writeCallbackHTML(w, `

Login failed

No token in response.

`) + return + } + + h.resCh <- &TokenInfo{AccessToken: token} + writeCallbackHTML(w, `

Login successful!

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 }