Skip to content
47 changes: 47 additions & 0 deletions cmd/auth-code.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package cmd

import (
"fmt"

"github.com/opentdf/otdfctl/pkg/cli"
"github.com/opentdf/otdfctl/pkg/handlers"
"github.com/opentdf/otdfctl/pkg/man"
"github.com/spf13/cobra"
)

func auth_codeLogin(cmd *cobra.Command, args []string) {
flagHelper := cli.NewFlagHelper(cmd)
host := flagHelper.GetRequiredString("host")
tlsNoVerify := flagHelper.GetOptionalBool("tls-no-verify")

tok, err := handlers.LoginWithPKCE(host, tlsNoVerify, noCacheCreds)
if err != nil {
cli.ExitWithError("could not authenticate", err)
}
if noCacheCreds {
fmt.Print(tok.AccessToken)
return
}
fmt.Println(cli.SuccessMessage("Successfully logged in with auth code PKCE flow. Credentials cached on native OS."))
}

var codeLoginCmd *man.Doc

func init() {
codeLoginCmd = man.Docs.GetCommand("auth/code-login",
man.WithRun(auth_codeLogin),
)
codeLoginCmd.Flags().StringP(
codeLoginCmd.GetDocFlag("client-id").Name,
codeLoginCmd.GetDocFlag("client-id").Shorthand,
codeLoginCmd.GetDocFlag("client-id").Default,
codeLoginCmd.GetDocFlag("client-id").Description,
)
codeLoginCmd.Flags().BoolVarP(
&noCacheCreds,
codeLoginCmd.GetDocFlag("no-cache").Name,
codeLoginCmd.GetDocFlag("no-cache").Shorthand,
codeLoginCmd.GetDocFlag("no-cache").DefaultAsBool(),
codeLoginCmd.GetDocFlag("no-cache").Description,
)
}
2 changes: 1 addition & 1 deletion cmd/auth-printAccessToken.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"github.com/spf13/cobra"
)

var printAccessToken = man.Docs.GetCommand("auth/print-access-token",
var printAccessTokenCmd = man.Docs.GetCommand("auth/print-access-token",
man.WithRun(auth_printAccessToken),
)

Expand Down
3 changes: 2 additions & 1 deletion cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import (
func init() {
cmd := man.Docs.GetCommand("auth",
man.WithSubcommands(clientCredentialsCmd),
man.WithSubcommands(printAccessToken),
man.WithSubcommands(printAccessTokenCmd),
man.WithSubcommands(clearCachedCredsCmd),
man.WithSubcommands(codeLoginCmd),
)

cmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
Expand Down
3 changes: 2 additions & 1 deletion cmd/dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,12 +167,13 @@ func NewHandler(cmd *cobra.Command) handlers.Handler {
clientCredsFile := flag.GetOptionalString("with-client-creds-file")
clientCredsJSON := flag.GetOptionalString("with-client-creds")

// Get any credentials we can from the cache or flags
creds, err := handlers.GetClientCreds(host, clientCredsFile, []byte(clientCredsJSON))
if err != nil {
cli.ExitWithError("Failed to get client credentials", err)
}

h, err := handlers.NewWithCredentials(host, creds.ClientID, creds.ClientSecret, tlsNoVerify)
h, err := handlers.NewWithCredentials(host, creds, tlsNoVerify)
if err != nil {
if errors.Is(err, handlers.ErrUnauthenticated) {
cli.ExitWithError(fmt.Sprintf("Not logged in. Please authenticate via CLI auth flow(s) before using command (%s %s)", cmd.Parent().Use, cmd.Use), err)
Expand Down
22 changes: 22 additions & 0 deletions docs/man/auth/code-login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
title: Open a browser and login with Auth Code PKCE

command:
name: code-login
flags:
- name: client-id
description: A clientId for use in auth code flow
shorthand: i
default: opentdf-public
required: true
- name: no-cache
description: Do not cache credentials on the native OS (print access token to stdout)
default: false
---

Authenticate for use of the OpenTDF Platform through a browser (required).

Provide a specific public 'client-id' known to support the Auth Code PKCE flow and recognized
by the OpenTDF Platform, or use the default `opentdf-public` client if not specified.

The OIDC Access Token will be stored in the OS-specific keychain by default, otherwise printed to `stdout` if `--no-cache` is passed.
195 changes: 194 additions & 1 deletion pkg/handlers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,23 @@ package handlers

import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"os/signal"
"runtime"
"syscall"
"time"

"github.com/golang-jwt/jwt/v4"
"github.com/zalando/go-keyring"
"golang.org/x/oauth2"
)

const (
Expand Down Expand Up @@ -157,7 +166,7 @@ func GetClientCreds(endpoint string, file string, credsJSON []byte) (ClientCreds
// Uses the OAuth2 client credentials flow to obtain a token.
func GetTokenWithClientCreds(ctx context.Context, endpoint string, clientID string, clientSecret string, tlsNoVerify bool) error {
// TODO improve the way we validate the client credentials
// sdk, err := NewWithCredentials(endpoint, clientID, clientSecret, tlsNoVerify)
// sdk, err := NewWithClientCredentials(endpoint, clientID, clientSecret, tlsNoVerify)
// if err != nil {
// return err
// }
Expand All @@ -175,3 +184,187 @@ func GetTokenWithClientCreds(ctx context.Context, endpoint string, clientID stri

return nil
}

type AuthorizationCodePKCE struct {
Oauth2Config *oauth2.Config
Token *oauth2.Token
}

type OpenTdfTokenSource struct {
OpenTdfToken *oauth2.Token
}

const (
opentdfPublicClientID = "opentdf-public"
authCodeFlowPort = "9000"
)

func (acp *AuthorizationCodePKCE) Login(platformEndpoint, tokenURL, authURL string, noPrint bool) (*oauth2.Token, error) {
var (
token *oauth2.Token
err error
)

// if a login is initiated, clear any existing token from the keyring proactively
keyring.Delete(platformEndpoint, OTDFCTL_OIDC_TOKEN_KEY)

conf := &oauth2.Config{
ClientID: opentdfPublicClientID,
Scopes: []string{"openid", "profile", "email"},
RedirectURL: fmt.Sprintf("http://localhost:%s/callback", authCodeFlowPort),
Endpoint: oauth2.Endpoint{
AuthURL: authURL,
TokenURL: tokenURL,
},
}
acp.Oauth2Config = conf

// Create a HTTP server to handle the callback ":9000"
srv := &http.Server{Addr: ":9000"}
stop := make(chan os.Signal, 1)

// Generate a code verifier and code challenge.
verifier, err := generateCodeVerifier()
if err != nil {
return nil, fmt.Errorf("failed to generate code verifier: %v", err)
}
challenge := generateCodeChallenge(verifier)

// Start a web server to handle the OAuth2 callback.
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
// Get the authorization code from the query parameters.
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "Missing authorization code", http.StatusBadRequest)
return
}

// Exchange the authorization code for an access token.
token, err = conf.Exchange(context.Background(), code, oauth2.SetAuthURLParam("code_verifier", verifier))
if err != nil {
http.Error(w, fmt.Sprintf("Failed to exchange authorization code: %v", err), http.StatusInternalServerError)
return
}

// Let the user know the flow was successful.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode("Return to the CLI to continue. You may close this tab.")

// Send a value to the stop channel to simulate the SIGINT signal.
stop <- syscall.SIGINT
})
url := conf.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("code_challenge", challenge), oauth2.SetAuthURLParam("code_challenge_method", "S256"), oauth2.SetAuthURLParam("audience", "http://localhost:8080"))

// avoid printing the help directions if not caching the token to avoid breaking scripts
if !noPrint {
fmt.Print("Open the following URL in a browser if it did not automatically open for you: ", url)
}
openBrowser(url)

// Start the HTTP server in a separate goroutine.
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
panic(fmt.Errorf("failed to start HTTP server: %w", err))
}
}()

// Wait for a SIGINT or SIGTERM signal to shutdown the server.
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

if err := srv.Shutdown(ctx); err != nil {
fmt.Printf("Failed to shutdown HTTP server gracefully: %v", err)
return nil, err
}
acp.Token = token
return token, nil
}

func (acp *AuthorizationCodePKCE) Client() (*http.Client, error) {
token, err := acp.Oauth2Config.TokenSource(context.Background(), acp.Token).Token()
if err != nil {
return nil, err
}
return acp.Oauth2Config.Client(context.Background(), token), nil
}

func openBrowser(url string) error {
var err error

switch runtime.GOOS {
case "linux":
err = exec.Command("xdg-open", url).Start()
case "darwin":
err = exec.Command("open", url).Start()
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
default:
err = fmt.Errorf("unsupported platform")
}

if err != nil {
return fmt.Errorf("failed to open browser: %v", err)
}

return nil
}

func generateCodeVerifier() (string, error) {
const codeVerifierLength = 32 // You can adjust the length of the code verifier as needed
randomBytes := make([]byte, codeVerifierLength)
_, err := rand.Read(randomBytes)
if err != nil {
return "", fmt.Errorf("failed to generate code verifier: %v", err)
}
return base64.RawURLEncoding.EncodeToString(randomBytes), nil
}

func generateCodeChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(hash[:])
}

func (ots *OpenTdfTokenSource) Token() (*oauth2.Token, error) {
return ots.OpenTdfToken, nil
}

func LoginWithPKCE(host string, tlsNoVerify bool, noCache bool) (*oauth2.Token, error) {
h, err := New(host, tlsNoVerify)
if err != nil {
return nil, fmt.Errorf("failed to create handler: %w", err)
}
tokenURL, err := h.Direct().PlatformTokenEndpoint()
if err != nil {
return nil, fmt.Errorf("failed to retrieve well-known token endpoint: %w", err)
}
authURL, err := h.Direct().PlatformAuthzEndpoint()
if err != nil {
return nil, fmt.Errorf("failed to retrieve well-known authz endpoint: %w", err)
}

acp := new(AuthorizationCodePKCE)

tok, err := acp.Login(h.platformEndpoint, tokenURL, authURL, noCache)
if err != nil {
return nil, fmt.Errorf("failed to login: %w", err)
}

if !noCache {
if err := keyring.Set(h.platformEndpoint, OTDFCTL_OIDC_TOKEN_KEY, tok.AccessToken); err != nil {
return nil, fmt.Errorf("failed to store token in keyring: %w", err)
}
}
return tok, nil
}

func buildTokenSource(token string) oauth2.TokenSource {
return &OpenTdfTokenSource{
OpenTdfToken: &oauth2.Token{
AccessToken: token,
},
}
}
13 changes: 11 additions & 2 deletions pkg/handlers/sdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,17 @@ type Handler struct {
platformEndpoint string
}

func NewWithCredentials(endpoint string, clientId string, clientSecret string, tlsNoVerify bool) (Handler, error) {
return New(endpoint, tlsNoVerify, sdk.WithClientCredentials(clientId, clientSecret, []string{"email"}))
func NewWithCredentials(endpoint string, creds ClientCreds, tlsNoVerify bool) (Handler, error) {
if creds.ClientID == "" || creds.ClientSecret == "" {
// try to get token from cache
tok, err := GetOIDCTokenFromCache(endpoint)
if err != nil {
return Handler{}, err
}
source := buildTokenSource(tok)
return New(endpoint, tlsNoVerify, sdk.WithCustomAccessTokenSource(source))
}
return New(endpoint, tlsNoVerify, sdk.WithClientCredentials(creds.ClientID, creds.ClientSecret, []string{"email"}))
}

// Creates a new handler wrapping the SDK, which is authenticated through the cached client-credentials flow tokens
Expand Down