mirror of
https://github.com/vmware-tanzu/pinniped.git
synced 2026-09-05 23:57:12 +00:00
Add initial "pinniped alpha login oidc" partial implementation.
Signed-off-by: Matt Moyer <moyerm@vmware.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
//nolint: gochecknoglobals
|
||||
var alphaCmd = &cobra.Command{
|
||||
Use: "alpha",
|
||||
Short: "alpha",
|
||||
Long: "alpha subcommands (syntax or flags are still subject to change)",
|
||||
SilenceUsage: true, // do not print usage message when commands fail
|
||||
Hidden: true,
|
||||
}
|
||||
|
||||
//nolint: gochecknoinits
|
||||
func init() {
|
||||
rootCmd.AddCommand(alphaCmd)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// mustMarkRequired marks the given flags as required on the provided cobra.Command. If any of the names are wrong, it panics.
|
||||
func mustMarkRequired(cmd *cobra.Command, flags ...string) {
|
||||
for _, flag := range flags {
|
||||
if err := cmd.MarkFlagRequired(flag); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMustMarkRequired(t *testing.T) {
|
||||
require.NotPanics(t, func() { mustMarkRequired(&cobra.Command{}) })
|
||||
require.NotPanics(t, func() {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().String("known-flag", "", "")
|
||||
mustMarkRequired(cmd, "known-flag")
|
||||
})
|
||||
require.Panics(t, func() { mustMarkRequired(&cobra.Command{}, "unknown-flag") })
|
||||
}
|
||||
@@ -85,15 +85,12 @@ func (c *getKubeConfigCommand) Command() *cobra.Command {
|
||||
`),
|
||||
}
|
||||
cmd.Flags().StringVar(&c.flags.token, "token", "", "Credential to include in the resulting kubeconfig output (Required)")
|
||||
err := cmd.MarkFlagRequired("token")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
cmd.Flags().StringVar(&c.flags.kubeconfig, "kubeconfig", c.flags.kubeconfig, "Path to the kubeconfig file")
|
||||
cmd.Flags().StringVar(&c.flags.contextOverride, "kubeconfig-context", c.flags.contextOverride, "Kubeconfig context override")
|
||||
cmd.Flags().StringVar(&c.flags.namespace, "pinniped-namespace", c.flags.namespace, "Namespace in which Pinniped was installed")
|
||||
cmd.Flags().StringVar(&c.flags.idpType, "idp-type", c.flags.idpType, "Identity provider type (e.g., 'webhook')")
|
||||
cmd.Flags().StringVar(&c.flags.idpName, "idp-name", c.flags.idpType, "Identity provider name")
|
||||
mustMarkRequired(cmd, "token")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
//nolint: gochecknoglobals
|
||||
var loginCmd = &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "login",
|
||||
Long: "Login to a Pinniped server",
|
||||
SilenceUsage: true, // do not print usage message when commands fail
|
||||
}
|
||||
|
||||
//nolint: gochecknoinits
|
||||
func init() {
|
||||
alphaCmd.AddCommand(loginCmd)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/coreos/go-oidc"
|
||||
"github.com/pkg/browser"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"go.pinniped.dev/internal/here"
|
||||
"go.pinniped.dev/internal/oidc/pkce"
|
||||
"go.pinniped.dev/internal/oidc/state"
|
||||
)
|
||||
|
||||
//nolint: gochecknoinits
|
||||
func init() {
|
||||
loginCmd.AddCommand((&oidcLoginParams{
|
||||
generateState: state.Generate,
|
||||
generatePKCE: pkce.Generate,
|
||||
openURL: browser.OpenURL,
|
||||
}).cmd())
|
||||
}
|
||||
|
||||
type oidcLoginParams struct {
|
||||
// These parameters capture CLI flags.
|
||||
issuer string
|
||||
clientID string
|
||||
listenPort uint16
|
||||
scopes []string
|
||||
skipBrowser bool
|
||||
usePKCE bool
|
||||
debugAuthCode bool
|
||||
|
||||
// These parameters capture dependencies that we want to mock during testing.
|
||||
generateState func() (state.State, error)
|
||||
generatePKCE func() (pkce.Code, error)
|
||||
openURL func(string) error
|
||||
}
|
||||
|
||||
func (o *oidcLoginParams) cmd() *cobra.Command {
|
||||
cmd := cobra.Command{
|
||||
Args: cobra.NoArgs,
|
||||
Use: "oidc --issuer ISSUER --client-id CLIENT_ID",
|
||||
Short: "Login using an OpenID Connect provider",
|
||||
RunE: o.runE,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
cmd.Flags().StringVar(&o.issuer, "issuer", "", "OpenID Connect issuer URL.")
|
||||
cmd.Flags().StringVar(&o.clientID, "client-id", "", "OpenID Connect client ID.")
|
||||
cmd.Flags().Uint16Var(&o.listenPort, "listen-port", 48095, "TCP port for localhost listener (authorization code flow only).")
|
||||
cmd.Flags().StringSliceVar(&o.scopes, "scopes", []string{"offline_access", "openid", "email", "profile"}, "OIDC scopes to request during login.")
|
||||
cmd.Flags().BoolVar(&o.skipBrowser, "skip-browser", false, "Skip opening the browser (just print the URL).")
|
||||
cmd.Flags().BoolVar(&o.usePKCE, "use-pkce", true, "Use Proof Key for Code Exchange (RFC 7636) during login.")
|
||||
mustMarkRequired(&cmd, "issuer", "client-id")
|
||||
|
||||
// TODO: temporary
|
||||
cmd.Flags().BoolVar(&o.debugAuthCode, "debug-auth-code-exchange", true, "Debug the authorization code exchange (temporary).")
|
||||
_ = cmd.Flags().MarkHidden("debug-auth-code-exchange")
|
||||
|
||||
return &cmd
|
||||
}
|
||||
|
||||
func (o *oidcLoginParams) runE(cmd *cobra.Command, args []string) error {
|
||||
metadata, err := oidc.NewProvider(cmd.Context(), o.issuer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not perform OIDC discovery for %q: %w", o.issuer, err)
|
||||
}
|
||||
|
||||
cfg := oauth2.Config{
|
||||
ClientID: o.clientID,
|
||||
Endpoint: metadata.Endpoint(),
|
||||
RedirectURL: fmt.Sprintf("http://localhost:%d/callback", o.listenPort),
|
||||
Scopes: o.scopes,
|
||||
}
|
||||
|
||||
authCodeOptions := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}
|
||||
|
||||
stateParam, err := o.generateState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not generate OIDC state parameter: %w", err)
|
||||
}
|
||||
|
||||
var pkceCode pkce.Code
|
||||
if o.usePKCE {
|
||||
pkceCode, err = o.generatePKCE()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not generate OIDC PKCE parameter: %w", err)
|
||||
}
|
||||
authCodeOptions = append(authCodeOptions, pkceCode.Challenge(), pkceCode.Method())
|
||||
}
|
||||
|
||||
// If --skip-browser was passed, override the default browser open function with a Printf() call.
|
||||
openURL := o.openURL
|
||||
if o.skipBrowser {
|
||||
openURL = func(s string) error {
|
||||
cmd.PrintErr("Please log in: ", s, "\n")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
authorizeURL := cfg.AuthCodeURL(stateParam.String(), authCodeOptions...)
|
||||
if err := openURL(authorizeURL); err != nil {
|
||||
return fmt.Errorf("could not open browser (run again with --skip-browser?): %w", err)
|
||||
}
|
||||
|
||||
// TODO: this temporary so we can complete the auth code exchange manually
|
||||
|
||||
if o.debugAuthCode {
|
||||
cmd.PrintErr(here.Docf(`
|
||||
DEBUG INFO:
|
||||
Token URL: %s
|
||||
State: %s
|
||||
PKCE: %s
|
||||
`,
|
||||
cfg.Endpoint.TokenURL,
|
||||
stateParam,
|
||||
pkceCode.Verifier(),
|
||||
))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"go.pinniped.dev/internal/here"
|
||||
"go.pinniped.dev/internal/oidc/pkce"
|
||||
"go.pinniped.dev/internal/oidc/state"
|
||||
)
|
||||
|
||||
func TestLoginOIDCCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantError bool
|
||||
wantStdout string
|
||||
wantStderr string
|
||||
}{
|
||||
{
|
||||
name: "help flag passed",
|
||||
args: []string{"--help"},
|
||||
wantStdout: here.Doc(`
|
||||
Login using an OpenID Connect provider
|
||||
|
||||
Usage:
|
||||
oidc --issuer ISSUER --client-id CLIENT_ID [flags]
|
||||
|
||||
Flags:
|
||||
--client-id string OpenID Connect client ID.
|
||||
-h, --help help for oidc
|
||||
--issuer string OpenID Connect issuer URL.
|
||||
--listen-port uint16 TCP port for localhost listener (authorization code flow only). (default 48095)
|
||||
--scopes strings OIDC scopes to request during login. (default [offline_access,openid,email,profile])
|
||||
--skip-browser Skip opening the browser (just print the URL).
|
||||
--use-pkce Use Proof Key for Code Exchange (RFC 7636) during login. (default true)
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "missing required flags",
|
||||
args: []string{},
|
||||
wantError: true,
|
||||
wantStdout: here.Doc(`
|
||||
Error: required flag(s) "client-id", "issuer" not set
|
||||
`),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd := (&oidcLoginParams{}).cmd()
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs(tt.args)
|
||||
err := cmd.Execute()
|
||||
if tt.wantError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, tt.wantStdout, stdout.String(), "unexpected stdout")
|
||||
require.Equal(t, tt.wantStderr, stderr.String(), "unexpected stderr")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCLoginRunE(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Start a server that returns 500 errors.
|
||||
brokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}))
|
||||
t.Cleanup(brokenServer.Close)
|
||||
|
||||
// Start a server that returns successfully.
|
||||
var validResponse string
|
||||
validServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(validResponse))
|
||||
}))
|
||||
t.Cleanup(validServer.Close)
|
||||
validResponse = strings.ReplaceAll(here.Docf(`
|
||||
{
|
||||
"issuer": "${ISSUER}",
|
||||
"authorization_endpoint": "${ISSUER}/auth",
|
||||
"token_endpoint": "${ISSUER}/token",
|
||||
"jwks_uri": "${ISSUER}/keys",
|
||||
"userinfo_endpoint": "${ISSUER}/userinfo",
|
||||
"grant_types_supported": ["authorization_code","refresh_token"],
|
||||
"response_types_supported": ["code"],
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
"scopes_supported": ["openid","email","groups","profile","offline_access"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_basic"],
|
||||
"claims_supported": ["aud","email","email_verified","exp","iat","iss","locale","name","sub"]
|
||||
}
|
||||
`), "${ISSUER}", validServer.URL)
|
||||
validServerURL, err := url.Parse(validServer.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
params oidcLoginParams
|
||||
wantError string
|
||||
wantStdout string
|
||||
wantStderr string
|
||||
wantStderrAuthURL func(*testing.T, *url.URL)
|
||||
}{
|
||||
{
|
||||
name: "broken discovery",
|
||||
params: oidcLoginParams{
|
||||
issuer: brokenServer.URL,
|
||||
},
|
||||
wantError: fmt.Sprintf("could not perform OIDC discovery for %q: 500 Internal Server Error: Internal Server Error\n", brokenServer.URL),
|
||||
},
|
||||
{
|
||||
name: "broken state generation",
|
||||
params: oidcLoginParams{
|
||||
issuer: validServer.URL,
|
||||
generateState: func() (state.State, error) { return "", fmt.Errorf("some error generating a state value") },
|
||||
},
|
||||
wantError: "could not generate OIDC state parameter: some error generating a state value",
|
||||
},
|
||||
{
|
||||
name: "broken PKCE generation",
|
||||
params: oidcLoginParams{
|
||||
issuer: validServer.URL,
|
||||
generateState: func() (state.State, error) { return "test-state", nil },
|
||||
usePKCE: true,
|
||||
generatePKCE: func() (pkce.Code, error) { return "", fmt.Errorf("some error generating a PKCE code") },
|
||||
},
|
||||
wantError: "could not generate OIDC PKCE parameter: some error generating a PKCE code",
|
||||
},
|
||||
{
|
||||
name: "broken browser open",
|
||||
params: oidcLoginParams{
|
||||
issuer: validServer.URL,
|
||||
generateState: func() (state.State, error) { return "test-state", nil },
|
||||
usePKCE: true,
|
||||
generatePKCE: func() (pkce.Code, error) { return "test-pkce", nil },
|
||||
openURL: func(_ string) error { return fmt.Errorf("some browser open error") },
|
||||
},
|
||||
wantError: "could not open browser (run again with --skip-browser?): some browser open error",
|
||||
},
|
||||
{
|
||||
name: "success without PKCE",
|
||||
params: oidcLoginParams{
|
||||
issuer: validServer.URL,
|
||||
clientID: "test-client-id",
|
||||
generateState: func() (state.State, error) { return "test-state", nil },
|
||||
usePKCE: false,
|
||||
listenPort: 12345,
|
||||
skipBrowser: true,
|
||||
},
|
||||
wantStderrAuthURL: func(t *testing.T, actual *url.URL) {
|
||||
require.Equal(t, validServerURL.Host, actual.Host)
|
||||
require.Equal(t, "/auth", actual.Path)
|
||||
require.Equal(t, "", actual.Fragment)
|
||||
require.Equal(t, url.Values{
|
||||
"access_type": []string{"offline"},
|
||||
"client_id": []string{"test-client-id"},
|
||||
"redirect_uri": []string{"http://localhost:12345/callback"},
|
||||
"response_type": []string{"code"},
|
||||
"state": []string{"test-state"},
|
||||
}, actual.Query())
|
||||
},
|
||||
wantStderr: "Please log in: <URL>\n",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd := cobra.Command{RunE: tt.params.runE, SilenceUsage: true, SilenceErrors: true}
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
err := cmd.Execute()
|
||||
if tt.wantError != "" {
|
||||
require.EqualError(t, err, tt.wantError)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
if tt.wantStderrAuthURL != nil {
|
||||
var urls []string
|
||||
redacted := regexp.MustCompile(`http://\S+`).ReplaceAllStringFunc(stderr.String(), func(url string) string {
|
||||
urls = append(urls, url)
|
||||
return "<URL>"
|
||||
})
|
||||
require.Lenf(t, urls, 1, "expected to find authorization URL in stderr:\n%s", stderr.String())
|
||||
authURL, err := url.Parse(urls[0])
|
||||
require.NoError(t, err, "invalid authorization URL")
|
||||
tt.wantStderrAuthURL(t, authURL)
|
||||
|
||||
// Replace the stderr buffer with the redacted version.
|
||||
stderr.Reset()
|
||||
stderr.WriteString(redacted)
|
||||
}
|
||||
|
||||
require.Equal(t, tt.wantStdout, stdout.String(), "unexpected stdout")
|
||||
require.Equal(t, tt.wantStderr, stderr.String(), "unexpected stderr")
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user