diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index a5d8b6d..c00a44f 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -98,6 +98,3 @@ scopes: - openid - session -user@host$ imscli login user -``` - diff --git a/cmd/obo_exchange.go b/cmd/obo_exchange.go new file mode 100644 index 0000000..12ef35d --- /dev/null +++ b/cmd/obo_exchange.go @@ -0,0 +1,46 @@ +// Copyright 2021 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 cmd + +import ( + "fmt" + + "github.com/adobe/imscli/ims" + "github.com/spf13/cobra" +) + +func oboExchangeCmd(imsConfig *ims.Config) *cobra.Command { + cmd := &cobra.Command{ + Use: "on-behalf-of", + Aliases: []string{"obo"}, + Short: "On-Behalf-Of token exchange.", + Long: `On-Behalf-Of token exchange: exchange a user access token for a new token. Do NOT send OBO access tokens to frontend clients.`, + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + resp, err := imsConfig.OBOExchange() + if err != nil { + return fmt.Errorf("error during On-Behalf-Of exchange: %v", err) + } + fmt.Println(resp.AccessToken) + return nil + }, + } + + cmd.Flags().StringVarP(&imsConfig.ClientID, "clientID", "c", "", "IMS client ID.") + cmd.Flags().StringVarP(&imsConfig.ClientSecret, "clientSecret", "p", "", "IMS client secret.") + cmd.Flags().StringVarP(&imsConfig.AccessToken, "accessToken", "t", "", "User access token (subject token). Only access tokens are accepted.") + cmd.Flags().StringSliceVarP(&imsConfig.Scopes, "scopes", "s", []string{""}, + "Scopes to request. Must be within the client's configured scope boundary.") + + return cmd +} diff --git a/cmd/root.go b/cmd/root.go index ea2cc4d..82fb517 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -47,6 +47,7 @@ func RootCmd(version string) *cobra.Command { cmd.PersistentFlags().IntVar(&imsConfig.Timeout, "timeout", 30, "HTTP client timeout in seconds.") cmd.AddCommand( + oboExchangeCmd(imsConfig), authzCmd(imsConfig), profileCmd(imsConfig), organizationsCmd(imsConfig), diff --git a/ims/config.go b/ims/config.go index c475ff7..bdced85 100644 --- a/ims/config.go +++ b/ims/config.go @@ -48,7 +48,7 @@ type Config struct { DecodeFulfillableData bool RegisterURL string ClientName string - RedirectURIs []string + RedirectURIs []string } // Access token information diff --git a/ims/obo_exchange.go b/ims/obo_exchange.go new file mode 100644 index 0000000..9eb7ba0 --- /dev/null +++ b/ims/obo_exchange.go @@ -0,0 +1,110 @@ +// Copyright 2021 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 ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// - Subject token restrictions: only user access tokens are accepted; ServiceTokens and +// impersonation tokens must not be used as the subject token. +// - Scope boundary: requested scopes cannot exceed the client's configured scopes. +// - Audit trail: the full actor chain is preserved in the act claim of the issued token. + +// OBO uses token v4 and RFC 8693 grant type per IMS OBO documentation. +const defaultOBOGrantType = "urn:ietf:params:oauth:grant-type:token-exchange" + +func (i Config) validateOBOExchangeConfig() error { + switch { + case i.URL == "": + return fmt.Errorf("missing IMS base URL parameter") + case i.ClientID == "": + return fmt.Errorf("missing client ID parameter") + case i.ClientSecret == "": + return fmt.Errorf("missing client secret parameter") + case i.AccessToken == "": + return fmt.Errorf("missing access token parameter (only access tokens are accepted)") + case len(i.Scopes) == 0 || (len(i.Scopes) == 1 && i.Scopes[0] == ""): + return fmt.Errorf("scopes are required for On-Behalf-Of exchange") + default: + return nil + } +} + +func (i Config) OBOExchange() (TokenInfo, error) { + if err := i.validateOBOExchangeConfig(); err != nil { + return TokenInfo{}, fmt.Errorf("invalid parameters for On-Behalf-Of exchange: %v", err) + } + + httpClient, err := i.httpClient() + if err != nil { + return TokenInfo{}, fmt.Errorf("error creating the HTTP Client: %v", err) + } + + data := url.Values{} + data.Set("grant_type", defaultOBOGrantType) + data.Set("client_id", i.ClientID) + data.Set("client_secret", i.ClientSecret) + data.Set("subject_token", i.AccessToken) + data.Set("subject_token_type", "urn:ietf:params:oauth:token-type:access_token") + data.Set("requested_token_type", "urn:ietf:params:oauth:token-type:access_token") + data.Set("scope", strings.Join(i.Scopes, ",")) + + // OBO Token Exchange requires /ims/token/v4 (v3 does not support this grant type). + tokenURL := fmt.Sprintf("%s/ims/token/v4?client_id=%s", strings.TrimSuffix(i.URL, "/"), url.QueryEscape(i.ClientID)) + req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(data.Encode())) + if err != nil { + return TokenInfo{}, fmt.Errorf("error creating On-Behalf-Of request: %v", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(req) + if err != nil { + return TokenInfo{}, fmt.Errorf("error during On-Behalf-Of exchange: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return TokenInfo{}, fmt.Errorf("error reading On-Behalf-Of response: %v", err) + } + + if resp.StatusCode != http.StatusOK { + errMsg := fmt.Sprintf("On-Behalf-Of exchange failed (status %d): %s", resp.StatusCode, string(body)) + if resp.StatusCode == http.StatusBadRequest { + if strings.Contains(string(body), "invalid_scope") { + errMsg += " — IMS may be rejecting the subject token's scopes for this client. Ensure the client has Token exchange enabled and allowed scopes in the portal, or try a user token obtained with fewer scopes." + } + } + return TokenInfo{}, fmt.Errorf("%s", errMsg) + } + + var out struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(body, &out); err != nil { + return TokenInfo{}, fmt.Errorf("error decoding On-Behalf-Of response: %v", err) + } + + expiresMs := out.ExpiresIn * int(time.Second/time.Millisecond) + + return TokenInfo{ + AccessToken: out.AccessToken, + Expires: expiresMs, + }, nil +}