Merge pull request #1249 from vmware-tanzu/username_scope

Add the new `username` scope to the Supervisor and exclude usernames from dynamic clients which are not granted the scope, and other dynamic client related changes
This commit is contained in:
Ryan Richard 2022-08-19 10:21:45 -07:00 committed by GitHub
commit 91cf439b31
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 2690 additions and 1134 deletions

View File

@ -15,11 +15,68 @@ const (
// or an LDAPIdentityProvider.
AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the name of the desired identity provider.
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the name of the desired identity provider.
AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name"
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the type of the desired identity provider.
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the type of the desired identity provider.
AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type"
// IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec.
IDTokenClaimIssuer = "iss"
// IDTokenClaimSubject is name of the subject claim defined by the OIDC spec.
IDTokenClaimSubject = "sub"
// IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec.
IDTokenClaimAuthorizedParty = "azp"
// IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's
// username which was mapped from the upstream identity provider.
IDTokenClaimUsername = "username"
// IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's
// group names which were mapped from the upstream identity provider.
IDTokenClaimGroups = "groups"
// GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec.
GrantTypeAuthorizationCode = "authorization_code"
// GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec.
GrantTypeRefreshToken = "refresh_token"
// GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges.
GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential
// ScopeOpenID is name of the openid scope defined by the OIDC spec.
ScopeOpenID = "openid"
// ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh
// tokens.
ScopeOfflineAccess = "offline_access"
// ScopeEmail is name of the email scope defined by the OIDC spec.
ScopeEmail = "email"
// ScopeProfile is name of the profile scope defined by the OIDC spec.
ScopeProfile = "profile"
// ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside
// ID tokens.
ScopeUsername = "username"
// ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside
// ID tokens.
ScopeGroups = "groups"
// ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to
// be used to request a different audience.
ScopeRequestAudience = "pinniped:request-audience"
// ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI.
ClientIDPinnipedCLI = "pinniped-cli"
// ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs.
ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-"
)

View File

@ -17,7 +17,7 @@ import (
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/spf13/cobra"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
@ -28,6 +28,7 @@ import (
conciergev1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1"
idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
conciergeclientset "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned"
"go.pinniped.dev/internal/groupsuffix"
"go.pinniped.dev/internal/net/phttp"
@ -126,9 +127,9 @@ func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command {
f.Var(&flags.concierge.mode, "concierge-mode", "Concierge mode of operation")
f.StringVar(&flags.oidc.issuer, "oidc-issuer", "", "OpenID Connect issuer URL (default: autodiscover)")
f.StringVar(&flags.oidc.clientID, "oidc-client-id", "pinniped-cli", "OpenID Connect client ID (default: autodiscover)")
f.StringVar(&flags.oidc.clientID, "oidc-client-id", oidcapi.ClientIDPinnipedCLI, "OpenID Connect client ID (default: autodiscover)")
f.Uint16Var(&flags.oidc.listenPort, "oidc-listen-port", 0, "TCP port for localhost listener (authorization code flow only)")
f.StringSliceVar(&flags.oidc.scopes, "oidc-scopes", []string{oidc.ScopeOfflineAccess, oidc.ScopeOpenID, "pinniped:request-audience"}, "OpenID Connect scopes to request during login")
f.StringSliceVar(&flags.oidc.scopes, "oidc-scopes", []string{oidcapi.ScopeOfflineAccess, oidcapi.ScopeOpenID, oidcapi.ScopeRequestAudience, oidcapi.ScopeUsername, oidcapi.ScopeGroups}, "OpenID Connect scopes to request during login")
f.BoolVar(&flags.oidc.skipBrowser, "oidc-skip-browser", false, "During OpenID Connect login, skip opening the browser (just print the URL)")
f.BoolVar(&flags.oidc.skipListen, "oidc-skip-listen", false, "During OpenID Connect login, skip starting a localhost callback listener (manual copy/paste flow only)")
f.StringVar(&flags.oidc.sessionCachePath, "oidc-session-cache", "", "Path to OpenID Connect session cache file")
@ -330,6 +331,9 @@ func newExecConfig(deps kubeconfigDeps, flags getKubeconfigParams) (*clientcmdap
execConfig.Args = append(execConfig.Args, "--debug-session-cache")
}
if flags.oidc.requestAudience != "" {
if strings.Contains(flags.oidc.requestAudience, ".pinniped.dev") {
return nil, fmt.Errorf("request audience is not allowed to include the substring '.pinniped.dev': %s", flags.oidc.requestAudience)
}
execConfig.Args = append(execConfig.Args, "--request-audience="+flags.oidc.requestAudience)
}
if flags.oidc.upstreamIDPName != "" {
@ -784,7 +788,7 @@ func newDiscoveryHTTPClient(caBundleFlag caBundleFlag) (*http.Client, error) {
}
func discoverIDPsDiscoveryEndpointURL(ctx context.Context, issuer string, httpClient *http.Client) (string, error) {
discoveredProvider, err := oidc.NewProvider(oidc.ClientContext(ctx, httpClient), issuer)
discoveredProvider, err := coreosoidc.NewProvider(coreosoidc.ClientContext(ctx, httpClient), issuer)
if err != nil {
return "", fmt.Errorf("while fetching OIDC discovery data from issuer: %w", err)
}

View File

@ -142,7 +142,7 @@ func TestGetKubeconfig(t *testing.T) {
--oidc-issuer string OpenID Connect issuer URL (default: autodiscover)
--oidc-listen-port uint16 TCP port for localhost listener (authorization code flow only)
--oidc-request-audience string Request a token with an alternate audience using RFC8693 token exchange
--oidc-scopes strings OpenID Connect scopes to request during login (default [offline_access,openid,pinniped:request-audience])
--oidc-scopes strings OpenID Connect scopes to request during login (default [offline_access,openid,pinniped:request-audience,username,groups])
--oidc-session-cache string Path to OpenID Connect session cache file
--oidc-skip-browser During OpenID Connect login, skip opening the browser (just print the URL)
-o, --output string Output file path (default: stdout)
@ -639,6 +639,77 @@ func TestGetKubeconfig(t *testing.T) {
return `Error: tried to autodiscover --oidc-ca-bundle, but JWTAuthenticator test-authenticator has invalid spec.tls.certificateAuthorityData: illegal base64 data at input byte 7` + "\n"
},
},
{
name: "autodetect JWT authenticator, invalid substring in audience",
args: func(issuerCABundle string, issuerURL string) []string {
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
}
},
conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object {
return []runtime.Object{
credentialIssuer(),
&conciergev1alpha1.JWTAuthenticator{
ObjectMeta: metav1.ObjectMeta{Name: "test-authenticator"},
Spec: conciergev1alpha1.JWTAuthenticatorSpec{
Issuer: issuerURL,
Audience: "some-test-audience.pinniped.dev-invalid-substring",
TLS: &conciergev1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(issuerCABundle)),
},
},
},
}
},
oidcDiscoveryResponse: happyOIDCDiscoveryResponse,
wantLogs: func(issuerCABundle string, issuerURL string) []string {
return []string{
`"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`,
`"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`,
`"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`,
`"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`,
`"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`,
fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL),
`"level"=0 "msg"="discovered OIDC audience" "audience"="some-test-audience.pinniped.dev-invalid-substring"`,
`"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`,
}
},
wantError: true,
wantStderr: func(issuerCABundle string, issuerURL string) string {
return `Error: request audience is not allowed to include the substring '.pinniped.dev': some-test-audience.pinniped.dev-invalid-substring` + "\n"
},
},
{
name: "autodetect JWT authenticator, override audience value, invalid substring in audience override value",
args: func(issuerCABundle string, issuerURL string) []string {
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
"--oidc-request-audience", "some-test-audience.pinniped.dev-invalid-substring",
}
},
conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object {
return []runtime.Object{
credentialIssuer(),
jwtAuthenticator(issuerCABundle, issuerURL),
}
},
oidcDiscoveryResponse: happyOIDCDiscoveryResponse,
wantLogs: func(issuerCABundle string, issuerURL string) []string {
return []string{
`"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`,
`"level"=0 "msg"="discovered Concierge operating in TokenCredentialRequest API mode"`,
`"level"=0 "msg"="discovered Concierge endpoint" "endpoint"="https://fake-server-url-value"`,
`"level"=0 "msg"="discovered Concierge certificate authority bundle" "roots"=0`,
`"level"=0 "msg"="discovered JWTAuthenticator" "name"="test-authenticator"`,
fmt.Sprintf(`"level"=0 "msg"="discovered OIDC issuer" "issuer"="%s"`, issuerURL),
`"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`,
}
},
wantError: true,
wantStderr: func(issuerCABundle string, issuerURL string) string {
return `Error: request audience is not allowed to include the substring '.pinniped.dev': some-test-audience.pinniped.dev-invalid-substring` + "\n"
},
},
{
name: "fail to get self-path",
args: func(issuerCABundle string, issuerURL string) []string {
@ -1290,7 +1361,7 @@ func TestGetKubeconfig(t *testing.T) {
- oidc
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --upstream-identity-provider-name=some-ldap-idp
- --upstream-identity-provider-type=ldap
@ -1496,7 +1567,7 @@ func TestGetKubeconfig(t *testing.T) {
- --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ==
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --request-audience=test-audience
command: '.../path/to/pinniped'
@ -1577,7 +1648,7 @@ func TestGetKubeconfig(t *testing.T) {
- --credential-cache=/path/to/cache/dir/credentials.yaml
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --skip-browser
- --skip-listen
- --listen-port=1234
@ -1695,7 +1766,7 @@ func TestGetKubeconfig(t *testing.T) {
- --concierge-ca-bundle-data=%s
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --request-audience=test-audience
command: '.../path/to/pinniped'
@ -1804,7 +1875,7 @@ func TestGetKubeconfig(t *testing.T) {
- --concierge-ca-bundle-data=dGVzdC1jb25jaWVyZ2UtY2E=
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --request-audience=test-audience
command: '.../path/to/pinniped'
@ -1881,7 +1952,7 @@ func TestGetKubeconfig(t *testing.T) {
- --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ==
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --request-audience=test-audience
- --upstream-identity-provider-name=some-ldap-idp
@ -1960,7 +2031,7 @@ func TestGetKubeconfig(t *testing.T) {
- --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ==
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --request-audience=test-audience
- --upstream-identity-provider-name=some-oidc-idp
@ -2037,7 +2108,7 @@ func TestGetKubeconfig(t *testing.T) {
- --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ==
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --request-audience=test-audience
command: '.../path/to/pinniped'
@ -2110,7 +2181,7 @@ func TestGetKubeconfig(t *testing.T) {
- --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ==
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --request-audience=test-audience
command: '.../path/to/pinniped'
@ -2190,7 +2261,7 @@ func TestGetKubeconfig(t *testing.T) {
- --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ==
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --request-audience=test-audience
command: '.../path/to/pinniped'
@ -2265,7 +2336,7 @@ func TestGetKubeconfig(t *testing.T) {
- --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ==
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --request-audience=test-audience
- --upstream-identity-provider-name=some-oidc-idp
@ -2348,7 +2419,7 @@ func TestGetKubeconfig(t *testing.T) {
- --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ==
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --request-audience=test-audience
- --upstream-identity-provider-name=some-oidc-idp
@ -2408,7 +2479,7 @@ func TestGetKubeconfig(t *testing.T) {
- oidc
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --upstream-identity-provider-name=some-ldap-idp
- --upstream-identity-provider-type=ldap
@ -2469,7 +2540,7 @@ func TestGetKubeconfig(t *testing.T) {
- oidc
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --upstream-identity-provider-name=some-ldap-idp
- --upstream-identity-provider-type=ldap
@ -2530,7 +2601,7 @@ func TestGetKubeconfig(t *testing.T) {
- oidc
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --upstream-identity-provider-name=some-ldap-idp
- --upstream-identity-provider-type=ldap
@ -2592,7 +2663,7 @@ func TestGetKubeconfig(t *testing.T) {
- oidc
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --upstream-identity-provider-name=some-ldap-idp
- --upstream-identity-provider-type=ldap
@ -2654,7 +2725,7 @@ func TestGetKubeconfig(t *testing.T) {
- oidc
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --upstream-identity-provider-name=some-ldap-idp
- --upstream-identity-provider-type=ldap
@ -2715,7 +2786,7 @@ func TestGetKubeconfig(t *testing.T) {
- oidc
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --upstream-identity-provider-name=some-ldap-idp
- --upstream-identity-provider-type=ldap
@ -2775,7 +2846,7 @@ func TestGetKubeconfig(t *testing.T) {
- oidc
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --scopes=offline_access,openid,pinniped:request-audience,username,groups
- --ca-bundle-data=%s
- --upstream-identity-provider-name=some-ldap-idp
- --upstream-identity-provider-type=ldap

View File

@ -16,12 +16,12 @@ import (
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/spf13/cobra"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/execcredcache"
"go.pinniped.dev/internal/groupsuffix"
"go.pinniped.dev/internal/net/phttp"
@ -98,9 +98,9 @@ func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command {
conciergeNamespace string // unused now
)
cmd.Flags().StringVar(&flags.issuer, "issuer", "", "OpenID Connect issuer URL")
cmd.Flags().StringVar(&flags.clientID, "client-id", "pinniped-cli", "OpenID Connect client ID")
cmd.Flags().StringVar(&flags.clientID, "client-id", oidcapi.ClientIDPinnipedCLI, "OpenID Connect client ID")
cmd.Flags().Uint16Var(&flags.listenPort, "listen-port", 0, "TCP port for localhost listener (authorization code flow only)")
cmd.Flags().StringSliceVar(&flags.scopes, "scopes", []string{oidc.ScopeOfflineAccess, oidc.ScopeOpenID, "pinniped:request-audience"}, "OIDC scopes to request during login")
cmd.Flags().StringSliceVar(&flags.scopes, "scopes", []string{oidcapi.ScopeOfflineAccess, oidcapi.ScopeOpenID, oidcapi.ScopeRequestAudience, oidcapi.ScopeUsername, oidcapi.ScopeGroups}, "OIDC scopes to request during login")
cmd.Flags().BoolVar(&flags.skipBrowser, "skip-browser", false, "Skip opening the browser (just print the URL)")
cmd.Flags().BoolVar(&flags.skipListen, "skip-listen", false, "Skip starting a localhost callback listener (manual copy/paste flow only)")
cmd.Flags().StringVar(&flags.sessionCachePath, "session-cache", filepath.Join(mustGetConfigDir(), "sessions.yaml"), "Path to session cache file")

View File

@ -80,7 +80,7 @@ func TestLoginOIDCCommand(t *testing.T) {
--issuer string OpenID Connect issuer URL
--listen-port uint16 TCP port for localhost listener (authorization code flow only)
--request-audience string Request a token with an alternate audience using RFC8693 token exchange
--scopes strings OIDC scopes to request during login (default [offline_access,openid,pinniped:request-audience])
--scopes strings OIDC scopes to request during login (default [offline_access,openid,pinniped:request-audience,username,groups])
--session-cache string Path to session cache file (default "` + cfgDir + `/sessions.yaml")
--skip-browser Skip opening the browser (just print the URL)
--upstream-identity-provider-flow string The type of client flow to use with the upstream identity provider during login with a Supervisor (e.g. 'browser_authcode', 'cli_password')

View File

@ -15,11 +15,68 @@ const (
// or an LDAPIdentityProvider.
AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the name of the desired identity provider.
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the name of the desired identity provider.
AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name"
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the type of the desired identity provider.
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the type of the desired identity provider.
AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type"
// IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec.
IDTokenClaimIssuer = "iss"
// IDTokenClaimSubject is name of the subject claim defined by the OIDC spec.
IDTokenClaimSubject = "sub"
// IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec.
IDTokenClaimAuthorizedParty = "azp"
// IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's
// username which was mapped from the upstream identity provider.
IDTokenClaimUsername = "username"
// IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's
// group names which were mapped from the upstream identity provider.
IDTokenClaimGroups = "groups"
// GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec.
GrantTypeAuthorizationCode = "authorization_code"
// GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec.
GrantTypeRefreshToken = "refresh_token"
// GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges.
GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential
// ScopeOpenID is name of the openid scope defined by the OIDC spec.
ScopeOpenID = "openid"
// ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh
// tokens.
ScopeOfflineAccess = "offline_access"
// ScopeEmail is name of the email scope defined by the OIDC spec.
ScopeEmail = "email"
// ScopeProfile is name of the profile scope defined by the OIDC spec.
ScopeProfile = "profile"
// ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside
// ID tokens.
ScopeUsername = "username"
// ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside
// ID tokens.
ScopeGroups = "groups"
// ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to
// be used to request a different audience.
ScopeRequestAudience = "pinniped:request-audience"
// ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI.
ClientIDPinnipedCLI = "pinniped-cli"
// ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs.
ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-"
)

View File

@ -15,11 +15,68 @@ const (
// or an LDAPIdentityProvider.
AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the name of the desired identity provider.
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the name of the desired identity provider.
AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name"
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the type of the desired identity provider.
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the type of the desired identity provider.
AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type"
// IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec.
IDTokenClaimIssuer = "iss"
// IDTokenClaimSubject is name of the subject claim defined by the OIDC spec.
IDTokenClaimSubject = "sub"
// IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec.
IDTokenClaimAuthorizedParty = "azp"
// IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's
// username which was mapped from the upstream identity provider.
IDTokenClaimUsername = "username"
// IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's
// group names which were mapped from the upstream identity provider.
IDTokenClaimGroups = "groups"
// GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec.
GrantTypeAuthorizationCode = "authorization_code"
// GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec.
GrantTypeRefreshToken = "refresh_token"
// GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges.
GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential
// ScopeOpenID is name of the openid scope defined by the OIDC spec.
ScopeOpenID = "openid"
// ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh
// tokens.
ScopeOfflineAccess = "offline_access"
// ScopeEmail is name of the email scope defined by the OIDC spec.
ScopeEmail = "email"
// ScopeProfile is name of the profile scope defined by the OIDC spec.
ScopeProfile = "profile"
// ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside
// ID tokens.
ScopeUsername = "username"
// ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside
// ID tokens.
ScopeGroups = "groups"
// ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to
// be used to request a different audience.
ScopeRequestAudience = "pinniped:request-audience"
// ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI.
ClientIDPinnipedCLI = "pinniped-cli"
// ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs.
ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-"
)

View File

@ -15,11 +15,68 @@ const (
// or an LDAPIdentityProvider.
AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the name of the desired identity provider.
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the name of the desired identity provider.
AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name"
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the type of the desired identity provider.
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the type of the desired identity provider.
AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type"
// IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec.
IDTokenClaimIssuer = "iss"
// IDTokenClaimSubject is name of the subject claim defined by the OIDC spec.
IDTokenClaimSubject = "sub"
// IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec.
IDTokenClaimAuthorizedParty = "azp"
// IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's
// username which was mapped from the upstream identity provider.
IDTokenClaimUsername = "username"
// IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's
// group names which were mapped from the upstream identity provider.
IDTokenClaimGroups = "groups"
// GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec.
GrantTypeAuthorizationCode = "authorization_code"
// GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec.
GrantTypeRefreshToken = "refresh_token"
// GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges.
GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential
// ScopeOpenID is name of the openid scope defined by the OIDC spec.
ScopeOpenID = "openid"
// ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh
// tokens.
ScopeOfflineAccess = "offline_access"
// ScopeEmail is name of the email scope defined by the OIDC spec.
ScopeEmail = "email"
// ScopeProfile is name of the profile scope defined by the OIDC spec.
ScopeProfile = "profile"
// ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside
// ID tokens.
ScopeUsername = "username"
// ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside
// ID tokens.
ScopeGroups = "groups"
// ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to
// be used to request a different audience.
ScopeRequestAudience = "pinniped:request-audience"
// ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI.
ClientIDPinnipedCLI = "pinniped-cli"
// ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs.
ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-"
)

View File

@ -15,11 +15,68 @@ const (
// or an LDAPIdentityProvider.
AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the name of the desired identity provider.
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the name of the desired identity provider.
AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name"
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the type of the desired identity provider.
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the type of the desired identity provider.
AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type"
// IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec.
IDTokenClaimIssuer = "iss"
// IDTokenClaimSubject is name of the subject claim defined by the OIDC spec.
IDTokenClaimSubject = "sub"
// IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec.
IDTokenClaimAuthorizedParty = "azp"
// IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's
// username which was mapped from the upstream identity provider.
IDTokenClaimUsername = "username"
// IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's
// group names which were mapped from the upstream identity provider.
IDTokenClaimGroups = "groups"
// GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec.
GrantTypeAuthorizationCode = "authorization_code"
// GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec.
GrantTypeRefreshToken = "refresh_token"
// GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges.
GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential
// ScopeOpenID is name of the openid scope defined by the OIDC spec.
ScopeOpenID = "openid"
// ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh
// tokens.
ScopeOfflineAccess = "offline_access"
// ScopeEmail is name of the email scope defined by the OIDC spec.
ScopeEmail = "email"
// ScopeProfile is name of the profile scope defined by the OIDC spec.
ScopeProfile = "profile"
// ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside
// ID tokens.
ScopeUsername = "username"
// ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside
// ID tokens.
ScopeGroups = "groups"
// ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to
// be used to request a different audience.
ScopeRequestAudience = "pinniped:request-audience"
// ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI.
ClientIDPinnipedCLI = "pinniped-cli"
// ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs.
ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-"
)

View File

@ -15,11 +15,68 @@ const (
// or an LDAPIdentityProvider.
AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the name of the desired identity provider.
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the name of the desired identity provider.
AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name"
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the type of the desired identity provider.
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the type of the desired identity provider.
AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type"
// IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec.
IDTokenClaimIssuer = "iss"
// IDTokenClaimSubject is name of the subject claim defined by the OIDC spec.
IDTokenClaimSubject = "sub"
// IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec.
IDTokenClaimAuthorizedParty = "azp"
// IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's
// username which was mapped from the upstream identity provider.
IDTokenClaimUsername = "username"
// IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's
// group names which were mapped from the upstream identity provider.
IDTokenClaimGroups = "groups"
// GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec.
GrantTypeAuthorizationCode = "authorization_code"
// GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec.
GrantTypeRefreshToken = "refresh_token"
// GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges.
GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential
// ScopeOpenID is name of the openid scope defined by the OIDC spec.
ScopeOpenID = "openid"
// ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh
// tokens.
ScopeOfflineAccess = "offline_access"
// ScopeEmail is name of the email scope defined by the OIDC spec.
ScopeEmail = "email"
// ScopeProfile is name of the profile scope defined by the OIDC spec.
ScopeProfile = "profile"
// ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside
// ID tokens.
ScopeUsername = "username"
// ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside
// ID tokens.
ScopeGroups = "groups"
// ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to
// be used to request a different audience.
ScopeRequestAudience = "pinniped:request-audience"
// ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI.
ClientIDPinnipedCLI = "pinniped-cli"
// ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs.
ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-"
)

View File

@ -15,11 +15,68 @@ const (
// or an LDAPIdentityProvider.
AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the name of the desired identity provider.
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the name of the desired identity provider.
AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name"
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the type of the desired identity provider.
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the type of the desired identity provider.
AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type"
// IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec.
IDTokenClaimIssuer = "iss"
// IDTokenClaimSubject is name of the subject claim defined by the OIDC spec.
IDTokenClaimSubject = "sub"
// IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec.
IDTokenClaimAuthorizedParty = "azp"
// IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's
// username which was mapped from the upstream identity provider.
IDTokenClaimUsername = "username"
// IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's
// group names which were mapped from the upstream identity provider.
IDTokenClaimGroups = "groups"
// GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec.
GrantTypeAuthorizationCode = "authorization_code"
// GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec.
GrantTypeRefreshToken = "refresh_token"
// GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges.
GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential
// ScopeOpenID is name of the openid scope defined by the OIDC spec.
ScopeOpenID = "openid"
// ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh
// tokens.
ScopeOfflineAccess = "offline_access"
// ScopeEmail is name of the email scope defined by the OIDC spec.
ScopeEmail = "email"
// ScopeProfile is name of the profile scope defined by the OIDC spec.
ScopeProfile = "profile"
// ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside
// ID tokens.
ScopeUsername = "username"
// ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside
// ID tokens.
ScopeGroups = "groups"
// ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to
// be used to request a different audience.
ScopeRequestAudience = "pinniped:request-audience"
// ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI.
ClientIDPinnipedCLI = "pinniped-cli"
// ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs.
ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-"
)

View File

@ -15,11 +15,68 @@ const (
// or an LDAPIdentityProvider.
AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the name of the desired identity provider.
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the name of the desired identity provider.
AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name"
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the type of the desired identity provider.
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the type of the desired identity provider.
AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type"
// IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec.
IDTokenClaimIssuer = "iss"
// IDTokenClaimSubject is name of the subject claim defined by the OIDC spec.
IDTokenClaimSubject = "sub"
// IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec.
IDTokenClaimAuthorizedParty = "azp"
// IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's
// username which was mapped from the upstream identity provider.
IDTokenClaimUsername = "username"
// IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's
// group names which were mapped from the upstream identity provider.
IDTokenClaimGroups = "groups"
// GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec.
GrantTypeAuthorizationCode = "authorization_code"
// GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec.
GrantTypeRefreshToken = "refresh_token"
// GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges.
GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential
// ScopeOpenID is name of the openid scope defined by the OIDC spec.
ScopeOpenID = "openid"
// ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh
// tokens.
ScopeOfflineAccess = "offline_access"
// ScopeEmail is name of the email scope defined by the OIDC spec.
ScopeEmail = "email"
// ScopeProfile is name of the profile scope defined by the OIDC spec.
ScopeProfile = "profile"
// ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside
// ID tokens.
ScopeUsername = "username"
// ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside
// ID tokens.
ScopeGroups = "groups"
// ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to
// be used to request a different audience.
ScopeRequestAudience = "pinniped:request-audience"
// ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI.
ClientIDPinnipedCLI = "pinniped-cli"
// ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs.
ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-"
)

View File

@ -15,11 +15,68 @@ const (
// or an LDAPIdentityProvider.
AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the name of the desired identity provider.
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the name of the desired identity provider.
AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name"
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the type of the desired identity provider.
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the type of the desired identity provider.
AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type"
// IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec.
IDTokenClaimIssuer = "iss"
// IDTokenClaimSubject is name of the subject claim defined by the OIDC spec.
IDTokenClaimSubject = "sub"
// IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec.
IDTokenClaimAuthorizedParty = "azp"
// IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's
// username which was mapped from the upstream identity provider.
IDTokenClaimUsername = "username"
// IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's
// group names which were mapped from the upstream identity provider.
IDTokenClaimGroups = "groups"
// GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec.
GrantTypeAuthorizationCode = "authorization_code"
// GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec.
GrantTypeRefreshToken = "refresh_token"
// GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges.
GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential
// ScopeOpenID is name of the openid scope defined by the OIDC spec.
ScopeOpenID = "openid"
// ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh
// tokens.
ScopeOfflineAccess = "offline_access"
// ScopeEmail is name of the email scope defined by the OIDC spec.
ScopeEmail = "email"
// ScopeProfile is name of the profile scope defined by the OIDC spec.
ScopeProfile = "profile"
// ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside
// ID tokens.
ScopeUsername = "username"
// ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside
// ID tokens.
ScopeGroups = "groups"
// ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to
// be used to request a different audience.
ScopeRequestAudience = "pinniped:request-audience"
// ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI.
ClientIDPinnipedCLI = "pinniped-cli"
// ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs.
ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-"
)

View File

@ -15,11 +15,68 @@ const (
// or an LDAPIdentityProvider.
AuthorizePasswordHeaderName = "Pinniped-Password" //nolint:gosec // this is not a credential
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the name of the desired identity provider.
// AuthorizeUpstreamIDPNameParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the name of the desired identity provider.
AuthorizeUpstreamIDPNameParamName = "pinniped_idp_name"
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select which
// identity provider should be used for authentication by sending the type of the desired identity provider.
// AuthorizeUpstreamIDPTypeParamName is the name of the HTTP request parameter which can be used to help select
// which identity provider should be used for authentication by sending the type of the desired identity provider.
AuthorizeUpstreamIDPTypeParamName = "pinniped_idp_type"
// IDTokenClaimIssuer is name of the issuer claim defined by the OIDC spec.
IDTokenClaimIssuer = "iss"
// IDTokenClaimSubject is name of the subject claim defined by the OIDC spec.
IDTokenClaimSubject = "sub"
// IDTokenClaimAuthorizedParty is name of the authorized party claim defined by the OIDC spec.
IDTokenClaimAuthorizedParty = "azp"
// IDTokenClaimUsername is the name of a custom claim in the downstream ID token whose value will contain the user's
// username which was mapped from the upstream identity provider.
IDTokenClaimUsername = "username"
// IDTokenClaimGroups is the name of a custom claim in the downstream ID token whose value will contain the user's
// group names which were mapped from the upstream identity provider.
IDTokenClaimGroups = "groups"
// GrantTypeAuthorizationCode is the name of the grant type for authorization code flows defined by the OIDC spec.
GrantTypeAuthorizationCode = "authorization_code"
// GrantTypeRefreshToken is the name of the grant type for refresh flow defined by the OIDC spec.
GrantTypeRefreshToken = "refresh_token"
// GrantTypeTokenExchange is the name of a custom grant type for RFC8693 token exchanges.
GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential
// ScopeOpenID is name of the openid scope defined by the OIDC spec.
ScopeOpenID = "openid"
// ScopeOfflineAccess is name of the offline access scope defined by the OIDC spec, used for requesting refresh
// tokens.
ScopeOfflineAccess = "offline_access"
// ScopeEmail is name of the email scope defined by the OIDC spec.
ScopeEmail = "email"
// ScopeProfile is name of the profile scope defined by the OIDC spec.
ScopeProfile = "profile"
// ScopeUsername is the name of a custom scope that determines whether the username claim will be returned inside
// ID tokens.
ScopeUsername = "username"
// ScopeGroups is the name of a custom scope that determines whether the groups claim will be returned inside
// ID tokens.
ScopeGroups = "groups"
// ScopeRequestAudience is the name of a custom scope that determines whether a RFC8693 token exchange is allowed to
// be used to request a different audience.
ScopeRequestAudience = "pinniped:request-audience"
// ClientIDPinnipedCLI is the client ID of the statically defined public OIDC client which is used by the CLI.
ClientIDPinnipedCLI = "pinniped-cli"
// ClientIDRequiredOIDCClientPrefix is the required prefix for the metadata.name of OIDCClient CRs.
ClientIDRequiredOIDCClientPrefix = "client.oauth.pinniped.dev-"
)

View File

@ -21,6 +21,7 @@ import (
"k8s.io/klog/v2"
auth1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
authinformers "go.pinniped.dev/generated/latest/client/concierge/informers/externalversions/authentication/v1alpha1"
pinnipedcontroller "go.pinniped.dev/internal/controller"
pinnipedauthenticator "go.pinniped.dev/internal/controller/authenticator"
@ -32,8 +33,8 @@ import (
// These default values come from the way that the Supervisor issues and signs tokens. We make these
// the defaults for a JWTAuthenticator so that they can easily integrate with the Supervisor.
const (
defaultUsernameClaim = "username"
defaultGroupsClaim = "groups"
defaultUsernameClaim = oidcapi.IDTokenClaimUsername
defaultGroupsClaim = oidcapi.IDTokenClaimGroups
)
// defaultSupportedSigningAlgos returns the default signing algos that this JWTAuthenticator

View File

@ -15,6 +15,7 @@ import (
corev1informers "k8s.io/client-go/informers/core/v1"
"go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
pinnipedclientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned"
configInformers "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions/config/v1alpha1"
pinnipedcontroller "go.pinniped.dev/internal/controller"
@ -27,7 +28,7 @@ import (
const (
secretTypeToObserve = "storage.pinniped.dev/oidc-client-secret" //nolint:gosec // this is not a credential
oidcClientPrefixToObserve = "client.oauth.pinniped.dev-" //nolint:gosec // this is not a credential
oidcClientPrefixToObserve = oidcapi.ClientIDRequiredOIDCClientPrefix
)
type oidcClientWatcherController struct {

View File

@ -14,7 +14,7 @@ import (
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/go-logr/logr"
"golang.org/x/oauth2"
corev1 "k8s.io/api/core/v1"
@ -26,6 +26,7 @@ import (
corev1informers "k8s.io/client-go/informers/core/v1"
"go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
pinnipedclientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned"
idpinformers "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions/idp/v1alpha1"
"go.pinniped.dev/internal/constable"
@ -97,11 +98,11 @@ type UpstreamOIDCIdentityProviderICache interface {
type lruValidatorCache struct{ cache *cache.Expiring }
type lruValidatorCacheEntry struct {
provider *oidc.Provider
provider *coreosoidc.Provider
client *http.Client
}
func (c *lruValidatorCache) getProvider(spec *v1alpha1.OIDCIdentityProviderSpec) (*oidc.Provider, *http.Client) {
func (c *lruValidatorCache) getProvider(spec *v1alpha1.OIDCIdentityProviderSpec) (*coreosoidc.Provider, *http.Client) {
if result, ok := c.cache.Get(c.cacheKey(spec)); ok {
entry := result.(*lruValidatorCacheEntry)
return entry.provider, entry.client
@ -109,7 +110,7 @@ func (c *lruValidatorCache) getProvider(spec *v1alpha1.OIDCIdentityProviderSpec)
return nil, nil
}
func (c *lruValidatorCache) putProvider(spec *v1alpha1.OIDCIdentityProviderSpec, provider *oidc.Provider, client *http.Client) {
func (c *lruValidatorCache) putProvider(spec *v1alpha1.OIDCIdentityProviderSpec, provider *coreosoidc.Provider, client *http.Client) {
c.cache.Set(c.cacheKey(spec), &lruValidatorCacheEntry{provider: provider, client: client}, oidcValidatorCacheTTL)
}
@ -129,8 +130,8 @@ type oidcWatcherController struct {
oidcIdentityProviderInformer idpinformers.OIDCIdentityProviderInformer
secretInformer corev1informers.SecretInformer
validatorCache interface {
getProvider(*v1alpha1.OIDCIdentityProviderSpec) (*oidc.Provider, *http.Client)
putProvider(*v1alpha1.OIDCIdentityProviderSpec, *oidc.Provider, *http.Client)
getProvider(*v1alpha1.OIDCIdentityProviderSpec) (*coreosoidc.Provider, *http.Client)
putProvider(*v1alpha1.OIDCIdentityProviderSpec, *coreosoidc.Provider, *http.Client)
}
}
@ -329,7 +330,7 @@ func (c *oidcWatcherController) validateIssuer(ctx context.Context, upstream *v1
return issuerURLCondition
}
discoveredProvider, err = oidc.NewProvider(oidc.ClientContext(ctx, httpClient), upstream.Spec.Issuer)
discoveredProvider, err = coreosoidc.NewProvider(coreosoidc.ClientContext(ctx, httpClient), upstream.Spec.Issuer)
if err != nil {
c.log.V(plog.KlogLevelTrace).WithValues(
"namespace", upstream.Namespace,
@ -457,12 +458,12 @@ func defaultClientShortTimeout(rootCAs *x509.CertPool) *http.Client {
func computeScopes(additionalScopes []string) []string {
// If none are set then provide a reasonable default which only tries to use scopes defined in the OIDC spec.
if len(additionalScopes) == 0 {
return []string{"openid", "offline_access", "email", "profile"}
return []string{oidcapi.ScopeOpenID, oidcapi.ScopeOfflineAccess, oidcapi.ScopeEmail, oidcapi.ScopeProfile}
}
// Otherwise, first compute the unique set of scopes, including "openid" (de-duplicate).
set := sets.NewString()
set.Insert("openid")
set.Insert(oidcapi.ScopeOpenID)
for _, s := range additionalScopes {
set.Insert(s)
}

View File

@ -9,7 +9,6 @@ import (
"fmt"
"time"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
@ -17,8 +16,8 @@ import (
"k8s.io/client-go/kubernetes"
"k8s.io/utils/clock"
clocktesting "k8s.io/utils/clock/testing"
"k8s.io/utils/strings/slices"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
pinnipedcontroller "go.pinniped.dev/internal/controller"
"go.pinniped.dev/internal/controllerlib"
"go.pinniped.dev/internal/crud"
@ -204,7 +203,7 @@ func (c *garbageCollectorController) maybeRevokeUpstreamOIDCToken(ctx context.Co
return err
}
pinnipedSession := accessTokenSession.Request.Session.(*psession.PinnipedSession)
if slices.Contains(accessTokenSession.Request.GetGrantedScopes(), coreosoidc.ScopeOfflineAccess) {
if accessTokenSession.Request.GetGrantedScopes().Has(oidcapi.ScopeOfflineAccess) {
return nil
}
return c.tryRevokeUpstreamOIDCToken(ctx, pinnipedSession.Custom, secret)

View File

@ -263,13 +263,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
when("there are valid, expired authcode secrets which contain upstream refresh tokens", func() {
it.Before(func() {
activeOIDCAuthcodeSession := &authorizationcode.Session{
Version: "2",
Version: "3",
Active: true,
Request: &fosite.Request{
ID: "request-id-1",
Client: &clientregistry.Client{},
Session: &psession.PinnipedSession{
Custom: &psession.CustomSessionData{
Username: "should be ignored by garbage collector",
ProviderUID: "upstream-oidc-provider-uid",
ProviderName: "upstream-oidc-provider-name",
ProviderType: psession.ProviderTypeOIDC,
@ -307,13 +308,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
r.NoError(kubeClient.Tracker().Add(activeOIDCAuthcodeSessionSecret))
inactiveOIDCAuthcodeSession := &authorizationcode.Session{
Version: "2",
Version: "3",
Active: false,
Request: &fosite.Request{
ID: "request-id-2",
Client: &clientregistry.Client{},
Session: &psession.PinnipedSession{
Custom: &psession.CustomSessionData{
Username: "should be ignored by garbage collector",
ProviderUID: "upstream-oidc-provider-uid",
ProviderName: "upstream-oidc-provider-name",
ProviderType: psession.ProviderTypeOIDC,
@ -385,13 +387,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
when("there are valid, expired authcode secrets which contain upstream access tokens", func() {
it.Before(func() {
activeOIDCAuthcodeSession := &authorizationcode.Session{
Version: "2",
Version: "3",
Active: true,
Request: &fosite.Request{
ID: "request-id-1",
Client: &clientregistry.Client{},
Session: &psession.PinnipedSession{
Custom: &psession.CustomSessionData{
Username: "should be ignored by garbage collector",
ProviderUID: "upstream-oidc-provider-uid",
ProviderName: "upstream-oidc-provider-name",
ProviderType: psession.ProviderTypeOIDC,
@ -429,13 +432,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
r.NoError(kubeClient.Tracker().Add(activeOIDCAuthcodeSessionSecret))
inactiveOIDCAuthcodeSession := &authorizationcode.Session{
Version: "2",
Version: "3",
Active: false,
Request: &fosite.Request{
ID: "request-id-2",
Client: &clientregistry.Client{},
Session: &psession.PinnipedSession{
Custom: &psession.CustomSessionData{
Username: "should be ignored by garbage collector",
ProviderUID: "upstream-oidc-provider-uid",
ProviderName: "upstream-oidc-provider-name",
ProviderType: psession.ProviderTypeOIDC,
@ -507,13 +511,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
when("there is an invalid, expired authcode secret", func() {
it.Before(func() {
invalidOIDCAuthcodeSession := &authorizationcode.Session{
Version: "2",
Version: "3",
Active: true,
Request: &fosite.Request{
ID: "", // it is invalid for there to be a missing request ID
Client: &clientregistry.Client{},
Session: &psession.PinnipedSession{
Custom: &psession.CustomSessionData{
Username: "should be ignored by garbage collector",
ProviderUID: "upstream-oidc-provider-uid",
ProviderName: "upstream-oidc-provider-name",
ProviderType: psession.ProviderTypeOIDC,
@ -575,13 +580,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
when("there is a valid, expired authcode secret but its upstream name does not match any existing upstream", func() {
it.Before(func() {
wrongProviderNameOIDCAuthcodeSession := &authorizationcode.Session{
Version: "2",
Version: "3",
Active: true,
Request: &fosite.Request{
ID: "request-id-1",
Client: &clientregistry.Client{},
Session: &psession.PinnipedSession{
Custom: &psession.CustomSessionData{
Username: "should be ignored by garbage collector",
ProviderUID: "upstream-oidc-provider-uid",
ProviderName: "upstream-oidc-provider-name-will-not-match",
ProviderType: psession.ProviderTypeOIDC,
@ -645,13 +651,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
when("there is a valid, expired authcode secret but its upstream UID does not match any existing upstream", func() {
it.Before(func() {
wrongProviderNameOIDCAuthcodeSession := &authorizationcode.Session{
Version: "2",
Version: "3",
Active: true,
Request: &fosite.Request{
ID: "request-id-1",
Client: &clientregistry.Client{},
Session: &psession.PinnipedSession{
Custom: &psession.CustomSessionData{
Username: "should be ignored by garbage collector",
ProviderUID: "upstream-oidc-provider-uid-will-not-match",
ProviderName: "upstream-oidc-provider-name",
ProviderType: psession.ProviderTypeOIDC,
@ -715,13 +722,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
when("there is a valid, recently expired authcode secret but the upstream revocation fails", func() {
it.Before(func() {
activeOIDCAuthcodeSession := &authorizationcode.Session{
Version: "2",
Version: "3",
Active: true,
Request: &fosite.Request{
ID: "request-id-1",
Client: &clientregistry.Client{},
Session: &psession.PinnipedSession{
Custom: &psession.CustomSessionData{
Username: "should be ignored by garbage collector",
ProviderUID: "upstream-oidc-provider-uid",
ProviderName: "upstream-oidc-provider-name",
ProviderType: psession.ProviderTypeOIDC,
@ -819,13 +827,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
when("there is a valid, long-since expired authcode secret but the upstream revocation fails", func() {
it.Before(func() {
activeOIDCAuthcodeSession := &authorizationcode.Session{
Version: "2",
Version: "3",
Active: true,
Request: &fosite.Request{
ID: "request-id-1",
Client: &clientregistry.Client{},
Session: &psession.PinnipedSession{
Custom: &psession.CustomSessionData{
Username: "should be ignored by garbage collector",
ProviderUID: "upstream-oidc-provider-uid",
ProviderName: "upstream-oidc-provider-name",
ProviderType: psession.ProviderTypeOIDC,
@ -897,13 +906,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
when("there are valid, expired access token secrets which contain upstream refresh tokens", func() {
it.Before(func() {
offlineAccessGrantedOIDCAccessTokenSession := &accesstoken.Session{
Version: "2",
Version: "3",
Request: &fosite.Request{
GrantedScope: fosite.Arguments{"scope1", "scope2", "offline_access"},
ID: "request-id-1",
Client: &clientregistry.Client{},
Session: &psession.PinnipedSession{
Custom: &psession.CustomSessionData{
Username: "should be ignored by garbage collector",
ProviderUID: "upstream-oidc-provider-uid",
ProviderName: "upstream-oidc-provider-name",
ProviderType: psession.ProviderTypeOIDC,
@ -941,13 +951,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
r.NoError(kubeClient.Tracker().Add(offlineAccessGrantedOIDCAccessTokenSessionSecret))
offlineAccessNotGrantedOIDCAccessTokenSession := &accesstoken.Session{
Version: "2",
Version: "3",
Request: &fosite.Request{
GrantedScope: fosite.Arguments{"scope1", "scope2"},
ID: "request-id-2",
Client: &clientregistry.Client{},
Session: &psession.PinnipedSession{
Custom: &psession.CustomSessionData{
Username: "should be ignored by garbage collector",
ProviderUID: "upstream-oidc-provider-uid",
ProviderName: "upstream-oidc-provider-name",
ProviderType: psession.ProviderTypeOIDC,
@ -1019,13 +1030,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
when("there are valid, expired access token secrets which contain upstream access tokens", func() {
it.Before(func() {
offlineAccessGrantedOIDCAccessTokenSession := &accesstoken.Session{
Version: "2",
Version: "3",
Request: &fosite.Request{
GrantedScope: fosite.Arguments{"scope1", "scope2", "offline_access"},
ID: "request-id-1",
Client: &clientregistry.Client{},
Session: &psession.PinnipedSession{
Custom: &psession.CustomSessionData{
Username: "should be ignored by garbage collector",
ProviderUID: "upstream-oidc-provider-uid",
ProviderName: "upstream-oidc-provider-name",
ProviderType: psession.ProviderTypeOIDC,
@ -1063,13 +1075,14 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
r.NoError(kubeClient.Tracker().Add(offlineAccessGrantedOIDCAccessTokenSessionSecret))
offlineAccessNotGrantedOIDCAccessTokenSession := &accesstoken.Session{
Version: "2",
Version: "3",
Request: &fosite.Request{
GrantedScope: fosite.Arguments{"scope1", "scope2"},
ID: "request-id-2",
Client: &clientregistry.Client{},
Session: &psession.PinnipedSession{
Custom: &psession.CustomSessionData{
Username: "should be ignored by garbage collector",
ProviderUID: "upstream-oidc-provider-uid",
ProviderName: "upstream-oidc-provider-name",
ProviderType: psession.ProviderTypeOIDC,
@ -1141,12 +1154,13 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
when("there are valid, expired refresh secrets which contain upstream refresh tokens", func() {
it.Before(func() {
oidcRefreshSession := &refreshtoken.Session{
Version: "2",
Version: "3",
Request: &fosite.Request{
ID: "request-id-1",
Client: &clientregistry.Client{},
Session: &psession.PinnipedSession{
Custom: &psession.CustomSessionData{
Username: "should be ignored by garbage collector",
ProviderUID: "upstream-oidc-provider-uid",
ProviderName: "upstream-oidc-provider-name",
ProviderType: psession.ProviderTypeOIDC,
@ -1217,12 +1231,13 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
when("there are valid, expired refresh secrets which contain upstream access tokens", func() {
it.Before(func() {
oidcRefreshSession := &refreshtoken.Session{
Version: "2",
Version: "3",
Request: &fosite.Request{
ID: "request-id-1",
Client: &clientregistry.Client{},
Session: &psession.PinnipedSession{
Custom: &psession.CustomSessionData{
Username: "should be ignored by garbage collector",
ProviderUID: "upstream-oidc-provider-uid",
ProviderName: "upstream-oidc-provider-name",
ProviderType: psession.ProviderTypeOIDC,

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package accesstoken
@ -29,7 +29,8 @@ const (
// Version 1 was the initial release of storage.
// Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request.
accessTokenStorageVersion = "2"
// Version 3 is when we added the Username field to the psession.CustomSessionData.
accessTokenStorageVersion = "3"
)
type RevocationStorage interface {

View File

@ -53,7 +53,7 @@ func TestAccessTokenStorage(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`),
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/access-token",
@ -122,7 +122,7 @@ func TestAccessTokenStorageRevocation(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`),
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/access-token",
@ -195,7 +195,7 @@ func TestWrongVersion(t *testing.T) {
_, err = storage.GetAccessTokenSession(ctx, "fancy-signature", nil)
require.EqualError(t, err, "access token request data has wrong version: access token session for fancy-signature has version not-the-right-version instead of 2")
require.EqualError(t, err, "access token request data has wrong version: access token session for fancy-signature has version not-the-right-version instead of 3")
}
func TestNilSessionRequest(t *testing.T) {
@ -213,7 +213,7 @@ func TestNilSessionRequest(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"2"}`),
"pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"3"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/access-token",
@ -297,13 +297,13 @@ func TestReadFromSecret(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"2","active": true}`),
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"3","active": true}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/access-token",
},
wantSession: &Session{
Version: "2",
Version: "3",
Request: &fosite.Request{
ID: "abcd-1",
Client: &clientregistry.Client{},
@ -313,6 +313,7 @@ func TestReadFromSecret(t *testing.T) {
Subject: "panda",
},
Custom: &psession.CustomSessionData{
Username: "fake-username",
ProviderUID: "fake-provider-uid",
ProviderName: "fake-provider-name",
ProviderType: "fake-provider-type",
@ -335,7 +336,7 @@ func TestReadFromSecret(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"2","active": true}`),
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"3","active": true}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/not-access-token",
@ -358,7 +359,7 @@ func TestReadFromSecret(t *testing.T) {
},
Type: "storage.pinniped.dev/access-token",
},
wantErr: "access token request data has wrong version: access token session has version wrong-version-here instead of 2",
wantErr: "access token request data has wrong version: access token session has version wrong-version-here instead of 3",
},
{
name: "missing request",
@ -371,7 +372,7 @@ func TestReadFromSecret(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"version":"2","active": true}`),
"pinniped-storage-data": []byte(`{"version":"3","active": true}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/access-token",

View File

@ -30,7 +30,8 @@ const (
// Version 1 was the initial release of storage.
// Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request.
authorizeCodeStorageVersion = "2"
// Version 3 is when we added the Username field to the psession.CustomSessionData.
authorizeCodeStorageVersion = "3"
)
var _ oauth2.AuthorizeCodeStorage = &authorizeCodeStorage{}
@ -366,45 +367,43 @@ const ExpectedAuthorizeCodeSessionJSONFromFuzzing = `{
"Subject": "\u0026¥潝邎Ȗ莅ŝǔ盕戙鵮碡ʯiŬŽ"
},
"custom": {
"providerUID": "Ĝ眧Ĭ",
"providerName": "ʼn2ƋŢ觛ǂ焺nŐǛ",
"providerType": "ɥ闣ʬ橳(ý綃ʃʚƟ覣k眐4",
"username": "Ĝ眧Ĭ",
"providerUID": "ʼn2ƋŢ觛ǂ焺nŐǛ",
"providerName": "ɥ闣ʬ橳(ý綃ʃʚƟ覣k眐4",
"providerType": "ȣ掘ʃƸ澺淗a紽ǒ|鰽",
"warnings": [
"掘ʃƸ澺淗a紽ǒ|鰽ŋ猊",
"毇妬\u003e6鉢緋uƴŤȱʀļÂ?"
"t毇妬\u003e6鉢緋uƴŤȱʀļÂ",
"虝27就伒犘c钡ɏȫ齁š"
],
"oidc": {
"upstreamRefreshToken": "\u003cƬb",
"upstreamAccessToken": "犘c钡ɏȫ",
"upstreamSubject": "",
"upstreamIssuer": "%OpKȱ藚ɏ¬Ê蒭堜"
"upstreamRefreshToken": "OpKȱ藚ɏ¬Ê蒭堜]ȗ韚ʫ繕ȫ碰+ʫ",
"upstreamAccessToken": "k9帴",
"upstreamSubject": "磊ůď逳鞪?3)藵睋邔\u0026Ű惫蜀Ģ",
"upstreamIssuer": ""
},
"ldap": {
"userDN": "ȗ韚ʫ繕ȫ碰+",
"userDN": "×",
"extraRefreshAttributes": {
"+î艔垎0": "ĝ",
"4İ": "墀jMʥ",
"k9帴": "磊ůď逳鞪?3)藵睋邔\u0026Ű惫蜀Ģ"
"ʥ笿0D": "s"
}
},
"activedirectory": {
"userDN": "%Ä摱ìÓȐĨf跞@)¿,ɭS隑i",
"userDN": "ĝ",
"extraRefreshAttributes": {
" 皦pSǬŝ社Vƅȭǝ*擦28Dž": "vư",
"艱iYn面@yȝƋ鬯犦獢9c5¤.岵": "浛a齙\\蹼偦歛"
"IȽ齤士bEǎ": "跞@)¿,ɭS隑ip偶宾儮猷V麹",
"ȝƋ鬯犦獢9c5¤.岵": "浛a齙\\蹼偦歛"
}
}
}
},
"requestedAudience": [
"置b",
"筫MN\u0026錝D肁Ŷɽ蔒PR}Ųʓl{"
" 皦pSǬŝ社Vƅȭǝ*擦28Dž",
""
],
"grantedAudience": [
"jÃ轘屔挝",
"Œų崓ļ憽-蹐È_¸]fś",
"ɵʮGɃɫ囤1+,Ȳ"
"置b",
"筫MN\u0026錝D肁Ŷɽ蔒PR}Ųʓl{"
]
},
"version": "2"
"version": "3"
}`

View File

@ -65,7 +65,7 @@ func TestAuthorizationCodeStorage(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"active":true,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`),
"pinniped-storage-data": []byte(`{"active":true,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/authcode",
@ -84,7 +84,7 @@ func TestAuthorizationCodeStorage(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"active":false,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`),
"pinniped-storage-data": []byte(`{"active":false,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/authcode",
@ -202,7 +202,7 @@ func TestWrongVersion(t *testing.T) {
_, err = storage.GetAuthorizeCodeSession(ctx, "fancy-signature", nil)
require.EqualError(t, err, "authorization request data has wrong version: authorization code session for fancy-signature has version not-the-right-version instead of 2")
require.EqualError(t, err, "authorization request data has wrong version: authorization code session for fancy-signature has version not-the-right-version instead of 3")
}
func TestNilSessionRequest(t *testing.T) {
@ -217,7 +217,7 @@ func TestNilSessionRequest(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value", "version":"2", "active": true}`),
"pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value", "version":"3", "active": true}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/authcode",
@ -384,7 +384,7 @@ func TestFuzzAndJSONNewValidEmptyAuthorizeCodeSession(t *testing.T) {
// set these to match CreateAuthorizeCodeSession so that .JSONEq works
validSession.Active = true
validSession.Version = "2"
validSession.Version = "3"
validSessionJSONBytes, err := json.MarshalIndent(validSession, "", "\t")
require.NoError(t, err)
@ -419,13 +419,13 @@ func TestReadFromSecret(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"2","active": true}`),
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"3","active": true}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/authcode",
},
wantSession: &Session{
Version: "2",
Version: "3",
Active: true,
Request: &fosite.Request{
ID: "abcd-1",
@ -436,6 +436,7 @@ func TestReadFromSecret(t *testing.T) {
Subject: "panda",
},
Custom: &psession.CustomSessionData{
Username: "fake-username",
ProviderUID: "fake-provider-uid",
ProviderName: "fake-provider-name",
ProviderType: "fake-provider-type",
@ -458,7 +459,7 @@ func TestReadFromSecret(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"2","active": true}`),
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"3","active": true}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/not-authcode",
@ -481,7 +482,7 @@ func TestReadFromSecret(t *testing.T) {
},
Type: "storage.pinniped.dev/authcode",
},
wantErr: "authorization request data has wrong version: authorization code session has version wrong-version-here instead of 2",
wantErr: "authorization request data has wrong version: authorization code session has version wrong-version-here instead of 3",
},
{
name: "missing request",
@ -494,7 +495,7 @@ func TestReadFromSecret(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"version":"2","active": true}`),
"pinniped-storage-data": []byte(`{"version":"3","active": true}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/authcode",

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package openidconnect
@ -30,7 +30,8 @@ const (
// Version 1 was the initial release of storage.
// Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request.
oidcStorageVersion = "2"
// Version 3 is when we added the Username field to the psession.CustomSessionData.
oidcStorageVersion = "3"
)
var _ openid.OpenIDConnectRequestStorage = &openIDConnectRequestStorage{}

View File

@ -52,7 +52,7 @@ func TestOpenIdConnectStorage(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`),
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/oidc",
@ -137,7 +137,7 @@ func TestWrongVersion(t *testing.T) {
_, err = storage.GetOpenIDConnectSession(ctx, "fancy-code.fancy-signature", nil)
require.EqualError(t, err, "oidc request data has wrong version: oidc session for fancy-signature has version not-the-right-version instead of 2")
require.EqualError(t, err, "oidc request data has wrong version: oidc session for fancy-signature has version not-the-right-version instead of 3")
}
func TestNilSessionRequest(t *testing.T) {
@ -152,7 +152,7 @@ func TestNilSessionRequest(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"2"}`),
"pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"3"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/oidc",

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package pkce
@ -28,7 +28,8 @@ const (
// Version 1 was the initial release of storage.
// Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request.
pkceStorageVersion = "2"
// Version 3 is when we added the Username field to the psession.CustomSessionData.
pkceStorageVersion = "3"
)
var _ pkce.PKCERequestStorage = &pkceStorage{}

View File

@ -52,7 +52,7 @@ func TestPKCEStorage(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`),
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/pkce",
@ -140,7 +140,7 @@ func TestWrongVersion(t *testing.T) {
_, err = storage.GetPKCERequestSession(ctx, "fancy-signature", nil)
require.EqualError(t, err, "pkce request data has wrong version: pkce session for fancy-signature has version not-the-right-version instead of 2")
require.EqualError(t, err, "pkce request data has wrong version: pkce session for fancy-signature has version not-the-right-version instead of 3")
}
func TestNilSessionRequest(t *testing.T) {
@ -158,7 +158,7 @@ func TestNilSessionRequest(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"2"}`),
"pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"3"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/pkce",

View File

@ -29,7 +29,8 @@ const (
// Version 1 was the initial release of storage.
// Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request.
refreshTokenStorageVersion = "2"
// Version 3 is when we added the Username field to the psession.CustomSessionData.
refreshTokenStorageVersion = "3"
)
type RevocationStorage interface {

View File

@ -52,7 +52,7 @@ func TestRefreshTokenStorage(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`),
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/refresh-token",
@ -122,7 +122,7 @@ func TestRefreshTokenStorageRevocation(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`),
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/refresh-token",
@ -177,7 +177,7 @@ func TestRefreshTokenStorageRevokeRefreshTokenMaybeGracePeriod(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`),
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"3"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/refresh-token",
@ -251,7 +251,7 @@ func TestWrongVersion(t *testing.T) {
_, err = storage.GetRefreshTokenSession(ctx, "fancy-signature", nil)
require.EqualError(t, err, "refresh token request data has wrong version: refresh token session for fancy-signature has version not-the-right-version instead of 2")
require.EqualError(t, err, "refresh token request data has wrong version: refresh token session for fancy-signature has version not-the-right-version instead of 3")
}
func TestNilSessionRequest(t *testing.T) {
@ -269,7 +269,7 @@ func TestNilSessionRequest(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"2"}`),
"pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"3"}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/refresh-token",
@ -353,13 +353,13 @@ func TestReadFromSecret(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"2","active": true}`),
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"3","active": true}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/refresh-token",
},
wantSession: &Session{
Version: "2",
Version: "3",
Request: &fosite.Request{
ID: "abcd-1",
Client: &clientregistry.Client{},
@ -369,6 +369,7 @@ func TestReadFromSecret(t *testing.T) {
Subject: "panda",
},
Custom: &psession.CustomSessionData{
Username: "fake-username",
ProviderUID: "fake-provider-uid",
ProviderName: "fake-provider-name",
ProviderType: "fake-provider-type",
@ -391,7 +392,7 @@ func TestReadFromSecret(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"2","active": true}`),
"pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"3","active": true}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/not-refresh-token",
@ -414,7 +415,7 @@ func TestReadFromSecret(t *testing.T) {
},
Type: "storage.pinniped.dev/refresh-token",
},
wantErr: "refresh token request data has wrong version: refresh token session has version wrong-version-here instead of 2",
wantErr: "refresh token request data has wrong version: refresh token session has version wrong-version-here instead of 3",
},
{
name: "missing request",
@ -427,7 +428,7 @@ func TestReadFromSecret(t *testing.T) {
},
},
Data: map[string][]byte{
"pinniped-storage-data": []byte(`{"version":"2","active": true}`),
"pinniped-storage-data": []byte(`{"version":"3","active": true}`),
"pinniped-storage-version": []byte("1"),
},
Type: "storage.pinniped.dev/refresh-token",

View File

@ -10,17 +10,15 @@ import (
"net/url"
"time"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/token/jwt"
"golang.org/x/oauth2"
supervisoroidc "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/httputil/securityheader"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/clientregistry"
"go.pinniped.dev/internal/oidc/csrftoken"
"go.pinniped.dev/internal/oidc/downstreamsession"
"go.pinniped.dev/internal/oidc/login"
@ -56,12 +54,12 @@ func NewHandler(
return httperr.Newf(http.StatusMethodNotAllowed, "%s (try GET or POST)", r.Method)
}
// Note that the client might have used supervisoroidc.AuthorizeUpstreamIDPNameParamName and
// supervisoroidc.AuthorizeUpstreamIDPTypeParamName query params to request a certain upstream IDP.
// Note that the client might have used oidcapi.AuthorizeUpstreamIDPNameParamName and
// oidcapi.AuthorizeUpstreamIDPTypeParamName query params to request a certain upstream IDP.
// The Pinniped CLI has been sending these params since v0.9.0.
// Currently, these are ignored because the Supervisor does not yet support logins when multiple IDPs
// are configured. However, these params should be honored in the future when choosing an upstream
// here, e.g. by calling supervisoroidc.FindUpstreamIDPByNameAndType() when the params are present.
// here, e.g. by calling oidcapi.FindUpstreamIDPByNameAndType() when the params are present.
oidcUpstream, ldapUpstream, idpType, err := chooseUpstreamIDP(idpLister)
if err != nil {
plog.WarningErr("authorize upstream config", err)
@ -69,8 +67,8 @@ func NewHandler(
}
if idpType == psession.ProviderTypeOIDC {
if len(r.Header.Values(supervisoroidc.AuthorizeUsernameHeaderName)) > 0 ||
len(r.Header.Values(supervisoroidc.AuthorizePasswordHeaderName)) > 0 {
if len(r.Header.Values(oidcapi.AuthorizeUsernameHeaderName)) > 0 ||
len(r.Header.Values(oidcapi.AuthorizePasswordHeaderName)) > 0 {
// The client set a username header, so they are trying to log in with a username/password.
return handleAuthRequestForOIDCUpstreamPasswordGrant(r, w, oauthHelperWithStorage, oidcUpstream)
}
@ -85,8 +83,8 @@ func NewHandler(
}
// We know it's an AD/LDAP upstream.
if len(r.Header.Values(supervisoroidc.AuthorizeUsernameHeaderName)) > 0 ||
len(r.Header.Values(supervisoroidc.AuthorizePasswordHeaderName)) > 0 {
if len(r.Header.Values(oidcapi.AuthorizeUsernameHeaderName)) > 0 ||
len(r.Header.Values(oidcapi.AuthorizePasswordHeaderName)) > 0 {
// The client set a username header, so they are trying to log in with a username/password.
return handleAuthRequestForLDAPUpstreamCLIFlow(r, w,
oauthHelperWithStorage,
@ -150,8 +148,9 @@ func handleAuthRequestForLDAPUpstreamCLIFlow(
subject := downstreamsession.DownstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse)
username = authenticateResponse.User.GetName()
groups := authenticateResponse.User.GetGroups()
customSessionData := downstreamsession.MakeDownstreamLDAPOrADCustomSessionData(ldapUpstream, idpType, authenticateResponse)
openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, authorizeRequester.GetGrantedScopes(), customSessionData)
customSessionData := downstreamsession.MakeDownstreamLDAPOrADCustomSessionData(ldapUpstream, idpType, authenticateResponse, username)
openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups,
authorizeRequester.GetGrantedScopes(), authorizeRequester.GetClient().GetID(), customSessionData)
oidc.PerformAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, openIDSession, true)
return nil
@ -244,7 +243,7 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant(
return nil
}
customSessionData, err := downstreamsession.MakeDownstreamOIDCCustomSessionData(oidcUpstream, token)
customSessionData, err := downstreamsession.MakeDownstreamOIDCCustomSessionData(oidcUpstream, token, username)
if err != nil {
oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester,
fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error()), true,
@ -252,7 +251,8 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant(
return nil
}
openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, authorizeRequester.GetGrantedScopes(), customSessionData)
openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups,
authorizeRequester.GetGrantedScopes(), authorizeRequester.GetClient().GetID(), customSessionData)
oidc.PerformAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, openIDSession, true)
@ -322,7 +322,7 @@ func handleAuthRequestForOIDCUpstreamBrowserFlow(
}
func requireStaticClientForUsernameAndPasswordHeaders(w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester) bool {
isStaticClient := authorizeRequester.GetClient().GetID() == clientregistry.PinnipedCLIClientID
isStaticClient := authorizeRequester.GetClient().GetID() == oidcapi.ClientIDPinnipedCLI
if !isStaticClient {
oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester,
fosite.ErrAccessDenied.WithHintf("This client is not allowed to submit username or password headers to this endpoint."), true)
@ -331,8 +331,8 @@ func requireStaticClientForUsernameAndPasswordHeaders(w http.ResponseWriter, oau
}
func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester) (string, string, bool) {
username := r.Header.Get(supervisoroidc.AuthorizeUsernameHeaderName)
password := r.Header.Get(supervisoroidc.AuthorizePasswordHeaderName)
username := r.Header.Get(oidcapi.AuthorizeUsernameHeaderName)
password := r.Header.Get(oidcapi.AuthorizePasswordHeaderName)
if username == "" || password == "" {
oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester,
fosite.ErrAccessDenied.WithHintf("Missing or blank username or password."), true)
@ -348,13 +348,13 @@ func newAuthorizeRequest(r *http.Request, w http.ResponseWriter, oauthHelper fos
return nil, false
}
// Automatically grant the openid, offline_access, pinniped:request-audience, and groups scopes, but only if they were requested.
// Automatically grant certain scopes, but only if they were requested.
// Grant the openid scope (for now) if they asked for it so that `NewAuthorizeResponse` will perform its OIDC validations.
// There don't seem to be any validations inside `NewAuthorizeResponse` related to the offline_access scope
// at this time, however we will temporarily grant the scope just in case that changes in a future release of fosite.
// This is instead of asking the user to approve these scopes. Note that `NewAuthorizeRequest` would have returned
// an error if the client requested a scope that they are not allowed to request, so we don't need to worry about that here.
downstreamsession.GrantScopesIfRequested(authorizeRequester, []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, oidc.RequestAudienceScope, oidc.DownstreamGroupsScope})
downstreamsession.AutoApproveScopes(authorizeRequester)
return authorizeRequester, true
}
@ -487,7 +487,7 @@ func handleBrowserFlowAuthRequest(
}
promptParam := r.Form.Get(promptParamName)
if promptParam == promptParamNone && oidc.ScopeWasRequested(authorizeRequester, coreosoidc.ScopeOpenID) {
if promptParam == promptParamNone && oidc.ScopeWasRequested(authorizeRequester, oidcapi.ScopeOpenID) {
oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrLoginRequired, false)
return nil, nil // already wrote the error response, don't return error
}
@ -538,8 +538,8 @@ func upstreamStateParam(
encoder oidc.Encoder,
) (string, error) {
stateParamData := oidc.UpstreamStateParamData{
// The auth params might have included supervisoroidc.AuthorizeUpstreamIDPNameParamName and
// supervisoroidc.AuthorizeUpstreamIDPTypeParamName, but those can be ignored by other handlers
// The auth params might have included oidcapi.AuthorizeUpstreamIDPNameParamName and
// oidcapi.AuthorizeUpstreamIDPTypeParamName, but those can be ignored by other handlers
// that are reading from the encoded upstream state param being built here.
// The UpstreamName and UpstreamType struct fields can be used instead.
// Remove those params here to avoid potential confusion about which should be used later.
@ -565,8 +565,8 @@ func removeCustomIDPParams(params url.Values) url.Values {
p[k] = v
}
// Remove the unnecessary params.
delete(p, supervisoroidc.AuthorizeUpstreamIDPNameParamName)
delete(p, supervisoroidc.AuthorizeUpstreamIDPTypeParamName)
delete(p, oidcapi.AuthorizeUpstreamIDPNameParamName)
delete(p, oidcapi.AuthorizeUpstreamIDPTypeParamName)
return p
}

View File

@ -34,6 +34,7 @@ import (
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/csrftoken"
"go.pinniped.dev/internal/oidc/jwks"
"go.pinniped.dev/internal/oidc/oidcclientvalidator"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/psession"
"go.pinniped.dev/internal/testutil"
@ -391,8 +392,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
return urlToReturn
}
happyDownstreamScopesRequested := []string{"openid", "profile", "email", "groups"}
happyDownstreamScopesGranted := []string{"openid", "groups"}
happyDownstreamScopesRequested := []string{"openid", "profile", "email", "username", "groups"}
happyDownstreamScopesGranted := []string{"openid", "username", "groups"}
happyGetRequestQueryMap := map[string]string{
"response_type": "code",
@ -465,6 +466,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
}
expectedHappyActiveDirectoryUpstreamCustomSession := &psession.CustomSessionData{
Username: happyLDAPUsernameFromAuthenticator,
ProviderUID: activeDirectoryUpstreamResourceUID,
ProviderName: activeDirectoryUpstreamName,
ProviderType: psession.ProviderTypeActiveDirectory,
@ -477,6 +479,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
}
expectedHappyLDAPUpstreamCustomSession := &psession.CustomSessionData{
Username: happyLDAPUsernameFromAuthenticator,
ProviderUID: ldapUpstreamResourceUID,
ProviderName: ldapUpstreamName,
ProviderType: psession.ProviderTypeLDAP,
@ -489,6 +492,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
}
expectedHappyOIDCPasswordGrantCustomSession := &psession.CustomSessionData{
Username: oidcUpstreamUsername,
ProviderUID: oidcPasswordGrantUpstreamResourceUID,
ProviderName: oidcPasswordGrantUpstreamName,
ProviderType: psession.ProviderTypeOIDC,
@ -499,7 +503,16 @@ func TestAuthorizationEndpoint(t *testing.T) {
},
}
expectedHappyOIDCPasswordGrantCustomSessionWithUsername := func(wantUsername string) *psession.CustomSessionData {
copyOfCustomSession := *expectedHappyOIDCPasswordGrantCustomSession
copyOfOIDC := *(expectedHappyOIDCPasswordGrantCustomSession.OIDC)
copyOfCustomSession.OIDC = &copyOfOIDC
copyOfCustomSession.Username = wantUsername
return &copyOfCustomSession
}
expectedHappyOIDCPasswordGrantCustomSessionWithAccessToken := &psession.CustomSessionData{
Username: oidcUpstreamUsername,
ProviderUID: oidcPasswordGrantUpstreamResourceUID,
ProviderName: oidcPasswordGrantUpstreamName,
ProviderType: psession.ProviderTypeOIDC,
@ -512,13 +525,14 @@ func TestAuthorizationEndpoint(t *testing.T) {
addFullyCapableDynamicClientAndSecretToKubeResources := func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) {
oidcClient, secret := testutil.FullyCapableOIDCClientAndStorageSecret(t,
"some-namespace", dynamicClientID, dynamicClientUID, downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost})
"some-namespace", dynamicClientID, dynamicClientUID, downstreamRedirectURI,
[]string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate)
require.NoError(t, supervisorClient.Tracker().Add(oidcClient))
require.NoError(t, kubeClient.Tracker().Add(secret))
}
// Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it
happyAuthcodeDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+groups&state=` + happyState
happyAuthcodeDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyState
incomingCookieCSRFValue := "csrf-value-from-cookie"
encodedIncomingCookieCSRFValue, err := happyCookieEncoder.Encode("csrf", incomingCookieCSRFValue)
@ -528,6 +542,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
name string
idps *oidctestutil.UpstreamIDPListerBuilder
kubeResources func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset)
generateCSRF func() (csrftoken.CSRFToken, error)
generatePKCE func() (pkce.Code, error)
generateNonce func() (nonce.Nonce, error)
@ -540,7 +555,6 @@ func TestAuthorizationEndpoint(t *testing.T) {
csrfCookie string
customUsernameHeader *string // nil means do not send header, empty means send header with empty value
customPasswordHeader *string // nil means do not send header, empty means send header with empty value
kubeResources func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset)
wantStatus int
wantContentType string
@ -1122,7 +1136,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation,
wantStatus: http.StatusFound,
wantContentType: htmlContentType,
wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid\+groups&state=` + happyState,
wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyState,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership,
@ -1145,7 +1159,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
customPasswordHeader: pointer.StringPtr(happyLDAPPassword),
wantStatus: http.StatusFound,
wantContentType: htmlContentType,
wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid\+groups&state=` + happyState,
wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyState,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
wantDownstreamIDTokenGroups: happyLDAPGroups,
@ -1219,6 +1233,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: &psession.CustomSessionData{
Username: oidcUpstreamUsername,
ProviderUID: oidcPasswordGrantUpstreamResourceUID,
ProviderName: oidcPasswordGrantUpstreamName,
ProviderType: psession.ProviderTypeOIDC,
@ -2373,13 +2388,13 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation,
wantStatus: http.StatusFound,
wantContentType: htmlContentType,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyState, // no scopes granted
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=username\+groups&state=` + happyState, // username and groups scopes were not requested, but are granted anyway for backwards compatibility
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership,
wantDownstreamRequestedScopes: []string{"email"}, // only email was requested
wantDownstreamIDTokenUsername: oidcUpstreamUsername, // username scope was not requested, but is granted anyway for backwards compatibility
wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, // groups scope was not requested, but is granted anyway for backwards compatibility
wantDownstreamRequestedScopes: []string{"email"}, // only email was requested
wantDownstreamRedirectURI: downstreamRedirectURI,
wantDownstreamGrantedScopes: []string{}, // no scopes granted
wantDownstreamGrantedScopes: []string{"username", "groups"}, // username and groups scopes were not requested, but are granted anyway for backwards compatibility
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
@ -2395,13 +2410,13 @@ func TestAuthorizationEndpoint(t *testing.T) {
customPasswordHeader: pointer.StringPtr(happyLDAPPassword),
wantStatus: http.StatusFound,
wantContentType: htmlContentType,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyState, // no scopes granted
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=username\+groups&state=` + happyState, // username and groups scopes were not requested, but are granted anyway for backwards compatibility
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
wantDownstreamIDTokenGroups: happyLDAPGroups,
wantDownstreamRequestedScopes: []string{"email"}, // only email was requested
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, // username scope was not requested, but is granted anyway for backwards compatibility
wantDownstreamIDTokenGroups: happyLDAPGroups, // groups scope was not requested, but is granted anyway for backwards compatibility
wantDownstreamRequestedScopes: []string{"email"}, // only email was requested
wantDownstreamRedirectURI: downstreamRedirectURI,
wantDownstreamGrantedScopes: []string{}, // no scopes granted
wantDownstreamGrantedScopes: []string{"username", "groups"}, // username and groups scopes were not requested, but are granted anyway for backwards compatibility
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
@ -2429,7 +2444,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession,
wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSessionWithUsername(oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped),
},
{
name: "OIDC upstream password grant: upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is missing",
@ -2455,7 +2470,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession,
wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSessionWithUsername("joe@whitehouse.gov"),
},
{
name: "OIDC upstream password grant: upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with true value",
@ -2482,7 +2497,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession,
wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSessionWithUsername("joe@whitehouse.gov"),
},
{
name: "OIDC upstream password grant: upstream IDP configures username claim as anything other than special claim `email` and `email_verified` upstream claim is present with false value",
@ -2510,7 +2525,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession,
wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSessionWithUsername("joe"),
},
{
name: "OIDC upstream password grant: upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with illegal value",
@ -2570,7 +2585,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantDownstreamNonce: downstreamNonce,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession,
wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSessionWithUsername(oidcUpstreamSubject),
},
{
name: "OIDC upstream password grant: upstream IDP's configured groups claim in the ID token has a non-array value",

View File

@ -8,7 +8,6 @@ import (
"net/http"
"net/url"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/ory/fosite"
"go.pinniped.dev/internal/httputil/httperr"
@ -53,10 +52,10 @@ func NewHandler(
return httperr.New(http.StatusBadRequest, "error using state downstream auth params")
}
// Automatically grant the openid, offline_access, pinniped:request-audience, and groups scopes, but only if they were requested.
// Automatically grant certain scopes, but only if they were requested.
// This is instead of asking the user to approve these scopes. Note that `NewAuthorizeRequest` would have returned
// an error if the client requested a scope that they are not allowed to request, so we don't need to worry about that here.
downstreamsession.GrantScopesIfRequested(authorizeRequester, []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, oidc.RequestAudienceScope, oidc.DownstreamGroupsScope})
downstreamsession.AutoApproveScopes(authorizeRequester)
token, err := upstreamIDPConfig.ExchangeAuthcodeAndValidateTokens(
r.Context(),
@ -75,12 +74,13 @@ func NewHandler(
return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err)
}
customSessionData, err := downstreamsession.MakeDownstreamOIDCCustomSessionData(upstreamIDPConfig, token)
customSessionData, err := downstreamsession.MakeDownstreamOIDCCustomSessionData(upstreamIDPConfig, token, username)
if err != nil {
return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err)
}
openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, authorizeRequester.GetGrantedScopes(), customSessionData)
openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups,
authorizeRequester.GetGrantedScopes(), authorizeRequester.GetClient().GetID(), customSessionData)
authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession)
if err != nil {

View File

@ -19,9 +19,11 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/jwks"
"go.pinniped.dev/internal/oidc/oidcclientvalidator"
"go.pinniped.dev/internal/psession"
"go.pinniped.dev/internal/testutil"
"go.pinniped.dev/internal/testutil/oidctestutil"
@ -66,8 +68,8 @@ const (
var (
oidcUpstreamGroupMembership = []string{"test-pinniped-group-0", "test-pinniped-group-1"}
happyDownstreamScopesRequested = []string{"openid", "groups"}
happyDownstreamScopesGranted = []string{"openid", "groups"}
happyDownstreamScopesRequested = []string{"openid", "username", "groups"}
happyDownstreamScopesGranted = []string{"openid", "username", "groups"}
happyDownstreamRequestParamsQuery = url.Values{
"response_type": []string{"code"},
@ -81,11 +83,13 @@ var (
}
happyDownstreamRequestParams = happyDownstreamRequestParamsQuery.Encode()
happyDownstreamRequestParamsForDynamicClient = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery,
happyDownstreamRequestParamsQueryForDynamicClient = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery,
map[string]string{"client_id": downstreamDynamicClientID},
).Encode()
)
happyDownstreamRequestParamsForDynamicClient = happyDownstreamRequestParamsQueryForDynamicClient.Encode()
happyDownstreamCustomSessionData = &psession.CustomSessionData{
Username: oidcUpstreamUsername,
ProviderUID: happyUpstreamIDPResourceUID,
ProviderName: happyUpstreamIDPName,
ProviderType: psession.ProviderTypeOIDC,
@ -95,7 +99,15 @@ var (
UpstreamSubject: oidcUpstreamSubject,
},
}
happyDownstreamCustomSessionDataWithUsername = func(wantUsername string) *psession.CustomSessionData {
copyOfCustomSession := *happyDownstreamCustomSessionData
copyOfOIDC := *(happyDownstreamCustomSessionData.OIDC)
copyOfCustomSession.OIDC = &copyOfOIDC
copyOfCustomSession.Username = wantUsername
return &copyOfCustomSession
}
happyDownstreamAccessTokenCustomSessionData = &psession.CustomSessionData{
Username: oidcUpstreamUsername,
ProviderUID: happyUpstreamIDPResourceUID,
ProviderName: happyUpstreamIDPName,
ProviderType: psession.ProviderTypeOIDC,
@ -143,11 +155,12 @@ func TestCallbackEndpoint(t *testing.T) {
}
// Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it
happyDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+groups&state=` + happyDownstreamState
happyDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyDownstreamState
addFullyCapableDynamicClientAndSecretToKubeResources := func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) {
oidcClient, secret := testutil.FullyCapableOIDCClientAndStorageSecret(t,
"some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost})
"some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, downstreamRedirectURI,
[]string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate)
require.NoError(t, supervisorClient.Tracker().Add(oidcClient))
require.NoError(t, kubeClient.Tracker().Add(secret))
}
@ -284,7 +297,7 @@ func TestCallbackEndpoint(t *testing.T) {
},
},
{
name: "form_post happy path with no groups scope requested",
name: "form_post happy path without username or groups scopes requested",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()),
method: http.MethodGet,
path: newRequestPath().WithState(
@ -298,14 +311,16 @@ func TestCallbackEndpoint(t *testing.T) {
).Encode(),
).Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusOK,
wantContentType: "text/html;charset=UTF-8",
wantBodyFormResponseRegexp: `<code id="manual-auth-code">(.+)</code>`,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamRequestedScopes: []string{"openid"},
wantDownstreamGrantedScopes: []string{"openid"},
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusOK,
wantContentType: "text/html;charset=UTF-8",
wantBodyFormResponseRegexp: `<code id="manual-auth-code">(.+)</code>`,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamRequestedScopes: []string{"openid"},
wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership,
// username and groups scopes were not requested but are granted anyway for the pinniped-cli client for backwards compatibility
wantDownstreamGrantedScopes: []string{"openid", "username", "groups"},
wantDownstreamNonce: downstreamNonce,
wantDownstreamClientID: downstreamPinnipedClientID,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
@ -335,6 +350,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: &psession.CustomSessionData{
Username: oidcUpstreamUsername,
ProviderUID: happyUpstreamIDPResourceUID,
ProviderName: happyUpstreamIDPName,
ProviderType: psession.ProviderTypeOIDC,
@ -370,7 +386,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantDownstreamClientID: downstreamPinnipedClientID,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: happyDownstreamCustomSessionData,
wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsername(oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped),
wantAuthcodeExchangeCall: &expectedAuthcodeExchange{
performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs,
@ -396,7 +412,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantDownstreamClientID: downstreamPinnipedClientID,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: happyDownstreamCustomSessionData,
wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsername("joe@whitehouse.gov"),
wantAuthcodeExchangeCall: &expectedAuthcodeExchange{
performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs,
@ -424,7 +440,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantDownstreamClientID: downstreamPinnipedClientID,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: happyDownstreamCustomSessionData,
wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsername("joe@whitehouse.gov"),
wantAuthcodeExchangeCall: &expectedAuthcodeExchange{
performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs,
@ -453,7 +469,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantDownstreamClientID: downstreamPinnipedClientID,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: happyDownstreamCustomSessionData,
wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsername("joe"),
wantAuthcodeExchangeCall: &expectedAuthcodeExchange{
performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs,
@ -584,7 +600,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantDownstreamClientID: downstreamPinnipedClientID,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: happyDownstreamCustomSessionData,
wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsername(oidcUpstreamSubject),
wantAuthcodeExchangeCall: &expectedAuthcodeExchange{
performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs,
@ -642,6 +658,152 @@ func TestCallbackEndpoint(t *testing.T) {
args: happyExchangeAndValidateTokensArgs,
},
},
{
name: "using dynamic client which is allowed to request username scope, but does not actually request username scope in authorize request, does not get username in ID token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
method: http.MethodGet,
path: newRequestPath().WithState(
happyUpstreamStateParamForDynamicClient().
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient,
map[string]string{"scope": "openid groups offline_access"}).Encode()).
Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+groups&state=` + happyDownstreamState,
wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamIDTokenUsername: "", // username scope was not requested
wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership,
wantDownstreamRequestedScopes: []string{"openid", "groups", "offline_access"},
wantDownstreamGrantedScopes: []string{"openid", "groups", "offline_access"},
wantDownstreamNonce: downstreamNonce,
wantDownstreamClientID: downstreamDynamicClientID,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: happyDownstreamCustomSessionData,
wantAuthcodeExchangeCall: &expectedAuthcodeExchange{
performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs,
},
},
{
name: "using dynamic client which is allowed to request groups scope, but does not actually request groups scope in authorize request, does not get groups in ID token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
method: http.MethodGet,
path: newRequestPath().WithState(
happyUpstreamStateParamForDynamicClient().
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient,
map[string]string{"scope": "openid username offline_access"}).Encode()).
Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+username&state=` + happyDownstreamState,
wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenGroups: nil, // groups scope was not requested
wantDownstreamRequestedScopes: []string{"openid", "username", "offline_access"},
wantDownstreamGrantedScopes: []string{"openid", "username", "offline_access"},
wantDownstreamNonce: downstreamNonce,
wantDownstreamClientID: downstreamDynamicClientID,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: happyDownstreamCustomSessionData,
wantAuthcodeExchangeCall: &expectedAuthcodeExchange{
performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs,
},
},
{
name: "using dynamic client which is not allowed to request username scope, and does not actually request username scope in authorize request, does not get username in ID token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()),
kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) {
oidcClient, secret := testutil.OIDCClientAndStorageSecret(t,
"some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID,
[]configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude username scope)
[]configv1alpha1.Scope{"openid", "offline_access", "groups"}, // username not allowed
downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate)
require.NoError(t, supervisorClient.Tracker().Add(oidcClient))
require.NoError(t, kubeClient.Tracker().Add(secret))
},
method: http.MethodGet,
path: newRequestPath().WithState(
happyUpstreamStateParam().WithAuthorizeRequestParams(
shallowCopyAndModifyQuery(
happyDownstreamRequestParamsQuery,
map[string]string{
"client_id": downstreamDynamicClientID,
"scope": "openid offline_access groups",
},
).Encode(),
).Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+groups&state=` + happyDownstreamState,
wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamIDTokenUsername: "", // username scope was not requested
wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership,
wantDownstreamRequestedScopes: []string{"openid", "groups", "offline_access"},
wantDownstreamGrantedScopes: []string{"openid", "groups", "offline_access"},
wantDownstreamNonce: downstreamNonce,
wantDownstreamClientID: downstreamDynamicClientID,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: happyDownstreamCustomSessionData,
wantAuthcodeExchangeCall: &expectedAuthcodeExchange{
performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs,
},
},
{
name: "using dynamic client which is not allowed to request groups scope, and does not actually request groups scope in authorize request, does not get groups in ID token",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()),
kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) {
oidcClient, secret := testutil.OIDCClientAndStorageSecret(t,
"some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID,
[]configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude groups scope)
[]configv1alpha1.Scope{"openid", "offline_access", "username"}, // groups not allowed
downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate)
require.NoError(t, supervisorClient.Tracker().Add(oidcClient))
require.NoError(t, kubeClient.Tracker().Add(secret))
},
method: http.MethodGet,
path: newRequestPath().WithState(
happyUpstreamStateParam().WithAuthorizeRequestParams(
shallowCopyAndModifyQuery(
happyDownstreamRequestParamsQuery,
map[string]string{
"client_id": downstreamDynamicClientID,
"scope": "openid offline_access username",
},
).Encode(),
).Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+username&state=` + happyDownstreamState,
wantBody: "",
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenGroups: nil, // groups scope was not requested
wantDownstreamRequestedScopes: []string{"openid", "username", "offline_access"},
wantDownstreamGrantedScopes: []string{"openid", "username", "offline_access"},
wantDownstreamNonce: downstreamNonce,
wantDownstreamClientID: downstreamDynamicClientID,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: happyDownstreamCustomSessionData,
wantAuthcodeExchangeCall: &expectedAuthcodeExchange{
performedByUpstreamName: happyUpstreamIDPName,
args: happyExchangeAndValidateTokensArgs,
},
},
// Pre-upstream-exchange verification
{
@ -718,7 +880,8 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet,
path: newRequestPath().WithState(
happyUpstreamStateParam().
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"prompt": "none login"}).Encode()).
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery,
map[string]string{"prompt": "none login"}).Encode()).
Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
@ -759,7 +922,8 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet,
path: newRequestPath().WithState(
happyUpstreamStateParam().
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"client_id": ""}).Encode()).
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery,
map[string]string{"client_id": ""}).Encode()).
Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
@ -773,7 +937,8 @@ func TestCallbackEndpoint(t *testing.T) {
method: http.MethodGet,
path: newRequestPath().WithState(
happyUpstreamStateParam().
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"client_id": "bogus"}).Encode()).
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery,
map[string]string{"client_id": "bogus"}).Encode()).
Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
@ -803,6 +968,64 @@ func TestCallbackEndpoint(t *testing.T) {
wantContentType: htmlContentType,
wantBody: "Bad Request: error using state downstream auth params\n",
},
{
name: "using dynamic client which is not allowed to request username scope in authorize request but requests it anyway",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()),
kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) {
oidcClient, secret := testutil.OIDCClientAndStorageSecret(t,
"some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID,
[]configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude username scope)
[]configv1alpha1.Scope{"openid", "offline_access", "groups"}, // username not allowed
downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate)
require.NoError(t, supervisorClient.Tracker().Add(oidcClient))
require.NoError(t, kubeClient.Tracker().Add(secret))
},
method: http.MethodGet,
path: newRequestPath().WithState(
happyUpstreamStateParam().WithAuthorizeRequestParams(
shallowCopyAndModifyQuery(
happyDownstreamRequestParamsQuery,
map[string]string{
"client_id": downstreamDynamicClientID,
"scope": "openid username",
},
).Encode(),
).Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusBadRequest,
wantContentType: htmlContentType,
wantBody: "Bad Request: error using state downstream auth params\n",
},
{
name: "using dynamic client which is not allowed to request groups scope in authorize request but requests it anyway",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()),
kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) {
oidcClient, secret := testutil.OIDCClientAndStorageSecret(t,
"some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID,
[]configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude groups scope)
[]configv1alpha1.Scope{"openid", "offline_access", "username"}, // groups not allowed
downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate)
require.NoError(t, supervisorClient.Tracker().Add(oidcClient))
require.NoError(t, kubeClient.Tracker().Add(secret))
},
method: http.MethodGet,
path: newRequestPath().WithState(
happyUpstreamStateParam().WithAuthorizeRequestParams(
shallowCopyAndModifyQuery(
happyDownstreamRequestParamsQuery,
map[string]string{
"client_id": downstreamDynamicClientID,
"scope": "openid groups",
},
).Encode(),
).Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusBadRequest,
wantContentType: htmlContentType,
wantBody: "Bad Request: error using state downstream auth params\n",
},
{
name: "state's downstream auth params does not contain openid scope",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()),
@ -810,16 +1033,17 @@ func TestCallbackEndpoint(t *testing.T) {
path: newRequestPath().
WithState(
happyUpstreamStateParam().
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"scope": "profile email groups"}).Encode()).
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery,
map[string]string{"scope": "profile username email groups"}).Encode()).
Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=groups&state=` + happyDownstreamState,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=username\+groups&state=` + happyDownstreamState,
wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamRequestedScopes: []string{"profile", "email", "groups"},
wantDownstreamGrantedScopes: []string{"groups"},
wantDownstreamRequestedScopes: []string{"profile", "email", "username", "groups"},
wantDownstreamGrantedScopes: []string{"username", "groups"},
wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership,
wantDownstreamNonce: downstreamNonce,
wantDownstreamClientID: downstreamPinnipedClientID,
@ -832,22 +1056,25 @@ func TestCallbackEndpoint(t *testing.T) {
},
},
{
name: "state's downstream auth params does not contain openid or groups scope",
name: "state's downstream auth params does not contain openid, username, or groups scope",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()),
method: http.MethodGet,
path: newRequestPath().
WithState(
happyUpstreamStateParam().
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"scope": "profile email"}).Encode()).
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery,
map[string]string{"scope": "profile email"}).Encode()).
Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyDownstreamState,
wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamRequestedScopes: []string{"profile", "email"},
wantDownstreamGrantedScopes: []string{},
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=username\+groups&state=` + happyDownstreamState,
wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamRequestedScopes: []string{"profile", "email"},
// username and groups scopes were not requested but are granted anyway for the pinniped-cli client for backwards compatibility
wantDownstreamGrantedScopes: []string{"username", "groups"},
wantDownstreamNonce: downstreamNonce,
wantDownstreamClientID: downstreamPinnipedClientID,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
@ -865,16 +1092,17 @@ func TestCallbackEndpoint(t *testing.T) {
path: newRequestPath().
WithState(
happyUpstreamStateParam().
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"scope": "openid offline_access groups"}).Encode()).
WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery,
map[string]string{"scope": "openid offline_access username groups"}).Encode()).
Build(t, happyStateCodec),
).String(),
csrfCookie: happyCSRFCookie,
wantStatus: http.StatusSeeOther,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+groups&state=` + happyDownstreamState,
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+username\+groups&state=` + happyDownstreamState,
wantDownstreamIDTokenUsername: oidcUpstreamUsername,
wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped,
wantDownstreamRequestedScopes: []string{"openid", "offline_access", "groups"},
wantDownstreamGrantedScopes: []string{"openid", "offline_access", "groups"},
wantDownstreamRequestedScopes: []string{"openid", "offline_access", "username", "groups"},
wantDownstreamGrantedScopes: []string{"openid", "offline_access", "username", "groups"},
wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership,
wantDownstreamNonce: downstreamNonce,
wantDownstreamClientID: downstreamPinnipedClientID,

View File

@ -10,25 +10,19 @@ import (
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/ory/fosite"
"k8s.io/apimachinery/pkg/api/errors"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
supervisorclient "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1"
"go.pinniped.dev/internal/oidc/oidcclientvalidator"
"go.pinniped.dev/internal/oidcclientsecretstorage"
"go.pinniped.dev/internal/plog"
)
const (
// PinnipedCLIClientID is the client ID of the statically defined public OIDC client which is used by the CLI.
PinnipedCLIClientID = "pinniped-cli"
requiredOIDCClientPrefix = "client.oauth.pinniped.dev-"
)
// Client represents a Pinniped OAuth/OIDC client. It can be the static pinniped-cli client
// or a dynamic client defined by an OIDCClient CR.
type Client struct {
@ -43,7 +37,7 @@ var (
)
func (c *Client) GetResponseModes() []fosite.ResponseModeType {
if c.ID == PinnipedCLIClientID {
if c.ID == oidcapi.ClientIDPinnipedCLI {
// The pinniped-cli client supports "" (unspecified), "query", and "form_post" response modes.
return []fosite.ResponseModeType{fosite.ResponseModeDefault, fosite.ResponseModeQuery, fosite.ResponseModeFormPost}
}
@ -78,12 +72,12 @@ func NewClientManager(
// Other errors returned are plain errors, because fosite will wrap them into a new ErrInvalidClient error and
// use the plain error's text as that error's debug message (see client_authentication.go in fosite).
func (m *ClientManager) GetClient(ctx context.Context, id string) (fosite.Client, error) {
if id == PinnipedCLIClientID {
if id == oidcapi.ClientIDPinnipedCLI {
// Return the static client. No lookups needed.
return PinnipedCLI(), nil
}
if !strings.HasPrefix(id, requiredOIDCClientPrefix) {
if !strings.HasPrefix(id, oidcapi.ClientIDRequiredOIDCClientPrefix) {
// It shouldn't really be possible to find this OIDCClient because the OIDCClient CRD validates the name prefix
// upon create, but just in case, don't even try to lookup clients which lack the required name prefix.
return nil, fosite.ErrNotFound.WithDescription("no such client")
@ -143,22 +137,23 @@ func PinnipedCLI() *Client {
return &Client{
DefaultOpenIDConnectClient: fosite.DefaultOpenIDConnectClient{
DefaultClient: &fosite.DefaultClient{
ID: PinnipedCLIClientID,
ID: oidcapi.ClientIDPinnipedCLI,
Secret: nil,
RedirectURIs: []string{"http://127.0.0.1/callback"},
GrantTypes: fosite.Arguments{
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:token-exchange",
oidcapi.GrantTypeAuthorizationCode,
oidcapi.GrantTypeRefreshToken,
oidcapi.GrantTypeTokenExchange,
},
ResponseTypes: []string{"code"},
Scopes: fosite.Arguments{
oidc.ScopeOpenID,
oidc.ScopeOfflineAccess,
"profile",
"email",
"pinniped:request-audience",
"groups",
oidcapi.ScopeOpenID,
oidcapi.ScopeOfflineAccess,
oidcapi.ScopeProfile,
oidcapi.ScopeEmail,
oidcapi.ScopeRequestAudience,
oidcapi.ScopeUsername,
oidcapi.ScopeGroups,
},
Audience: nil,
Public: true,
@ -167,7 +162,7 @@ func PinnipedCLI() *Client {
JSONWebKeys: nil,
JSONWebKeysURI: "",
RequestObjectSigningAlgorithm: "",
TokenEndpointAuthSigningAlgorithm: oidc.RS256,
TokenEndpointAuthSigningAlgorithm: coreosoidc.RS256,
TokenEndpointAuthMethod: "none",
},
}
@ -194,7 +189,7 @@ func oidcClientCRToFositeClient(oidcClient *configv1alpha1.OIDCClient, clientSec
JSONWebKeys: nil,
JSONWebKeysURI: "",
RequestObjectSigningAlgorithm: "",
TokenEndpointAuthSigningAlgorithm: oidc.RS256,
TokenEndpointAuthSigningAlgorithm: coreosoidc.RS256,
TokenEndpointAuthMethod: "client_secret_basic",
},
}

View File

@ -269,7 +269,7 @@ func requireEqualsPinnipedCLI(t *testing.T, c *Client) {
require.Equal(t, []string{"http://127.0.0.1/callback"}, c.GetRedirectURIs())
require.Equal(t, fosite.Arguments{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:token-exchange"}, c.GetGrantTypes())
require.Equal(t, fosite.Arguments{"code"}, c.GetResponseTypes())
require.Equal(t, fosite.Arguments{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, "profile", "email", "pinniped:request-audience", "groups"}, c.GetScopes())
require.Equal(t, fosite.Arguments{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, "profile", "email", "pinniped:request-audience", "username", "groups"}, c.GetScopes())
require.True(t, c.IsPublic())
require.Nil(t, c.GetAudience())
require.Nil(t, c.GetRequestURIs())
@ -302,6 +302,7 @@ func requireEqualsPinnipedCLI(t *testing.T, c *Client) {
"profile",
"email",
"pinniped:request-audience",
"username",
"groups"
],
"audience": null,

View File

@ -10,6 +10,7 @@ import (
"net/http"
"go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/oidc"
)
@ -68,8 +69,8 @@ func NewHandler(issuerURL string) http.Handler {
IDTokenSigningAlgValuesSupported: []string{"ES256"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic"},
CodeChallengeMethodsSupported: []string{"S256"},
ScopesSupported: []string{"openid", "offline"},
ClaimsSupported: []string{"groups"},
ScopesSupported: []string{oidcapi.ScopeOpenID, oidcapi.ScopeOfflineAccess, oidcapi.ScopeRequestAudience, oidcapi.ScopeUsername, oidcapi.ScopeGroups},
ClaimsSupported: []string{oidcapi.IDTokenClaimUsername, oidcapi.IDTokenClaimGroups},
}
var b bytes.Buffer

View File

@ -45,9 +45,9 @@ func TestDiscovery(t *testing.T) {
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["ES256"],
"token_endpoint_auth_methods_supported": ["client_secret_basic"],
"scopes_supported": ["openid", "offline"],
"scopes_supported": ["openid", "offline_access", "pinniped:request-audience", "username", "groups"],
"code_challenge_methods_supported": ["S256"],
"claims_supported": ["groups"],
"claims_supported": ["username", "groups"],
"discovery.supervisor.pinniped.dev/v1alpha1": {
"pinniped_identity_providers_endpoint": "https://some-issuer.com/some/path/v1alpha1/pinniped_identity_providers"
}

View File

@ -16,6 +16,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/strings/slices"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/authenticators"
"go.pinniped.dev/internal/constable"
"go.pinniped.dev/internal/oidc"
@ -27,7 +28,7 @@ import (
const (
// The name of the email claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
emailClaimName = "email"
emailClaimName = oidcapi.ScopeEmail
// The name of the email_verified claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
emailVerifiedClaimName = "email_verified"
@ -40,7 +41,14 @@ const (
)
// MakeDownstreamSession creates a downstream OIDC session.
func MakeDownstreamSession(subject string, username string, groups []string, grantedScopes []string, custom *psession.CustomSessionData) *psession.PinnipedSession {
func MakeDownstreamSession(
subject string,
username string,
groups []string,
grantedScopes []string,
clientID string,
custom *psession.CustomSessionData,
) *psession.PinnipedSession {
now := time.Now().UTC()
openIDSession := &psession.PinnipedSession{
Fosite: &openid.DefaultSession{
@ -55,12 +63,17 @@ func MakeDownstreamSession(subject string, username string, groups []string, gra
if groups == nil {
groups = []string{}
}
openIDSession.IDTokenClaims().Extra = map[string]interface{}{
oidc.DownstreamUsernameClaim: username,
extras := map[string]interface{}{}
extras[oidcapi.IDTokenClaimAuthorizedParty] = clientID
if slices.Contains(grantedScopes, oidcapi.ScopeUsername) {
extras[oidcapi.IDTokenClaimUsername] = username
}
if slices.Contains(grantedScopes, oidc.DownstreamGroupsScope) {
openIDSession.IDTokenClaims().Extra[oidc.DownstreamGroupsClaim] = groups
if slices.Contains(grantedScopes, oidcapi.ScopeGroups) {
extras[oidcapi.IDTokenClaimGroups] = groups
}
openIDSession.IDTokenClaims().Extra = extras
return openIDSession
}
@ -68,8 +81,10 @@ func MakeDownstreamLDAPOrADCustomSessionData(
ldapUpstream provider.UpstreamLDAPIdentityProviderI,
idpType psession.ProviderType,
authenticateResponse *authenticators.Response,
username string,
) *psession.CustomSessionData {
customSessionData := &psession.CustomSessionData{
Username: username,
ProviderUID: ldapUpstream.GetResourceUID(),
ProviderName: ldapUpstream.GetName(),
ProviderType: idpType,
@ -92,17 +107,22 @@ func MakeDownstreamLDAPOrADCustomSessionData(
return customSessionData
}
func MakeDownstreamOIDCCustomSessionData(oidcUpstream provider.UpstreamOIDCIdentityProviderI, token *oidctypes.Token) (*psession.CustomSessionData, error) {
upstreamSubject, err := ExtractStringClaimValue(oidc.IDTokenSubjectClaim, oidcUpstream.GetName(), token.IDToken.Claims)
func MakeDownstreamOIDCCustomSessionData(
oidcUpstream provider.UpstreamOIDCIdentityProviderI,
token *oidctypes.Token,
username string,
) (*psession.CustomSessionData, error) {
upstreamSubject, err := ExtractStringClaimValue(oidcapi.IDTokenClaimSubject, oidcUpstream.GetName(), token.IDToken.Claims)
if err != nil {
return nil, err
}
upstreamIssuer, err := ExtractStringClaimValue(oidc.IDTokenIssuerClaim, oidcUpstream.GetName(), token.IDToken.Claims)
upstreamIssuer, err := ExtractStringClaimValue(oidcapi.IDTokenClaimIssuer, oidcUpstream.GetName(), token.IDToken.Claims)
if err != nil {
return nil, err
}
customSessionData := &psession.CustomSessionData{
Username: username,
ProviderUID: oidcUpstream.GetResourceUID(),
ProviderName: oidcUpstream.GetName(),
ProviderType: psession.ProviderTypeOIDC,
@ -148,11 +168,30 @@ func MakeDownstreamOIDCCustomSessionData(oidcUpstream provider.UpstreamOIDCIdent
return customSessionData, nil
}
// GrantScopesIfRequested auto-grants the scopes for which we do not require end-user approval, if they were requested.
func GrantScopesIfRequested(authorizeRequester fosite.AuthorizeRequester, scopes []string) {
for _, scope := range scopes {
// AutoApproveScopes auto-grants the scopes which we support and for which we do not require end-user approval,
// if they were requested. This should only be called after it has been validated that the client is allowed to request
// the scopes that it requested (which is a check performed by fosite).
func AutoApproveScopes(authorizeRequester fosite.AuthorizeRequester) {
for _, scope := range []string{
oidcapi.ScopeOpenID,
oidcapi.ScopeOfflineAccess,
oidcapi.ScopeRequestAudience,
oidcapi.ScopeUsername,
oidcapi.ScopeGroups,
} {
oidc.GrantScopeIfRequested(authorizeRequester, scope)
}
// For backwards-compatibility with old pinniped CLI binaries which never request the username and groups scopes
// (because those scopes did not exist yet when those CLIs were released), grant/approve the username and groups
// scopes even if the CLI did not request them. Basically, pretend that the CLI requested them and auto-approve
// them. Newer versions of the CLI binaries will request these scopes, so after enough time has passed that
// we can assume the old versions of the CLI are no longer in use in the wild, then we can remove this code and
// just let the above logic handle all clients.
if authorizeRequester.GetClient().GetID() == oidcapi.ClientIDPinnipedCLI {
authorizeRequester.GrantScope(oidcapi.ScopeUsername)
authorizeRequester.GrantScope(oidcapi.ScopeGroups)
}
}
// GetDownstreamIdentityFromUpstreamIDToken returns the mapped subject, username, and group names, in that order.
@ -179,11 +218,11 @@ func getSubjectAndUsernameFromUpstreamIDToken(
) (string, string, error) {
// The spec says the "sub" claim is only unique per issuer,
// so we will prepend the issuer string to make it globally unique.
upstreamIssuer, err := ExtractStringClaimValue(oidc.IDTokenIssuerClaim, upstreamIDPConfig.GetName(), idTokenClaims)
upstreamIssuer, err := ExtractStringClaimValue(oidcapi.IDTokenClaimIssuer, upstreamIDPConfig.GetName(), idTokenClaims)
if err != nil {
return "", "", err
}
upstreamSubject, err := ExtractStringClaimValue(oidc.IDTokenSubjectClaim, upstreamIDPConfig.GetName(), idTokenClaims)
upstreamSubject, err := ExtractStringClaimValue(oidcapi.IDTokenClaimSubject, upstreamIDPConfig.GetName(), idTokenClaims)
if err != nil {
return "", "", err
}
@ -266,13 +305,13 @@ func DownstreamSubjectFromUpstreamLDAP(ldapUpstream provider.UpstreamLDAPIdentit
func DownstreamLDAPSubject(uid string, ldapURL url.URL) string {
q := ldapURL.Query()
q.Set(oidc.IDTokenSubjectClaim, uid)
q.Set(oidcapi.IDTokenClaimSubject, uid)
ldapURL.RawQuery = q.Encode()
return ldapURL.String()
}
func downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString string, upstreamSubject string) string {
return fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, oidc.IDTokenSubjectClaim, url.QueryEscape(upstreamSubject))
return fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, oidcapi.IDTokenClaimSubject, url.QueryEscape(upstreamSubject))
}
// GetGroupsFromUpstreamIDToken returns mapped group names coerced into a slice of strings.

View File

@ -7,7 +7,6 @@ import (
"net/http"
"net/url"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/ory/fosite"
"go.pinniped.dev/internal/httputil/httperr"
@ -46,10 +45,10 @@ func NewPostHandler(issuerURL string, upstreamIDPs oidc.UpstreamIdentityProvider
return httperr.New(http.StatusBadRequest, "error using state downstream auth params")
}
// Automatically grant the openid, offline_access, pinniped:request-audience and groups scopes, but only if they were requested.
// Automatically grant certain scopes, but only if they were requested.
// This is instead of asking the user to approve these scopes. Note that `NewAuthorizeRequest` would have returned
// an error if the client requested a scope that they are not allowed to request, so we don't need to worry about that here.
downstreamsession.GrantScopesIfRequested(authorizeRequester, []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, oidc.RequestAudienceScope, oidc.DownstreamGroupsScope})
downstreamsession.AutoApproveScopes(authorizeRequester)
// Get the username and password form params from the POST body.
username := r.PostFormValue(usernameParamName)
@ -83,8 +82,9 @@ func NewPostHandler(issuerURL string, upstreamIDPs oidc.UpstreamIdentityProvider
subject := downstreamsession.DownstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse)
username = authenticateResponse.User.GetName()
groups := authenticateResponse.User.GetGroups()
customSessionData := downstreamsession.MakeDownstreamLDAPOrADCustomSessionData(ldapUpstream, idpType, authenticateResponse)
openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, authorizeRequester.GetGrantedScopes(), customSessionData)
customSessionData := downstreamsession.MakeDownstreamLDAPOrADCustomSessionData(ldapUpstream, idpType, authenticateResponse, username)
openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups,
authorizeRequester.GetGrantedScopes(), authorizeRequester.GetClient().GetID(), customSessionData)
oidc.PerformAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, openIDSession, false)
return nil

View File

@ -17,10 +17,12 @@ import (
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/client-go/kubernetes/fake"
configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake"
"go.pinniped.dev/internal/authenticators"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/jwks"
"go.pinniped.dev/internal/oidc/oidcclientvalidator"
"go.pinniped.dev/internal/psession"
"go.pinniped.dev/internal/testutil"
"go.pinniped.dev/internal/testutil/oidctestutil"
@ -86,8 +88,8 @@ func TestPostLoginEndpoint(t *testing.T) {
}
)
happyDownstreamScopesRequested := []string{"openid", "groups"}
happyDownstreamScopesGranted := []string{"openid", "groups"}
happyDownstreamScopesRequested := []string{"openid", "username", "groups"}
happyDownstreamScopesGranted := []string{"openid", "username", "groups"}
happyDownstreamRequestParamsQuery := url.Values{
"response_type": []string{"code"},
@ -192,6 +194,7 @@ func TestPostLoginEndpoint(t *testing.T) {
}
expectedHappyActiveDirectoryUpstreamCustomSession := &psession.CustomSessionData{
Username: happyLDAPUsernameFromAuthenticator,
ProviderUID: activeDirectoryUpstreamResourceUID,
ProviderName: activeDirectoryUpstreamName,
ProviderType: psession.ProviderTypeActiveDirectory,
@ -204,6 +207,7 @@ func TestPostLoginEndpoint(t *testing.T) {
}
expectedHappyLDAPUpstreamCustomSession := &psession.CustomSessionData{
Username: happyLDAPUsernameFromAuthenticator,
ProviderUID: ldapUpstreamResourceUID,
ProviderName: ldapUpstreamName,
ProviderType: psession.ProviderTypeLDAP,
@ -216,7 +220,7 @@ func TestPostLoginEndpoint(t *testing.T) {
}
// Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it
happyAuthcodeDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+groups&state=` + happyDownstreamState
happyAuthcodeDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyDownstreamState
happyUsernamePasswordFormParams := url.Values{userParam: []string{happyLDAPUsername}, passParam: []string{happyLDAPPassword}}
@ -237,7 +241,8 @@ func TestPostLoginEndpoint(t *testing.T) {
addFullyCapableDynamicClientAndSecretToKubeResources := func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) {
oidcClient, secret := testutil.FullyCapableOIDCClientAndStorageSecret(t,
"some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost})
"some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, downstreamRedirectURI,
[]string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate)
require.NoError(t, supervisorClient.Tracker().Add(oidcClient))
require.NoError(t, kubeClient.Tracker().Add(secret))
}
@ -413,7 +418,7 @@ func TestPostLoginEndpoint(t *testing.T) {
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantBodyString: "",
wantRedirectLocationRegexp: "http://127.0.0.1:4242/callback" + `\?code=([^&]+)&scope=openid\+groups&state=` + happyDownstreamState,
wantRedirectLocationRegexp: "http://127.0.0.1:4242/callback" + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyDownstreamState,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
wantDownstreamIDTokenGroups: happyLDAPGroups,
@ -439,7 +444,7 @@ func TestPostLoginEndpoint(t *testing.T) {
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantBodyString: "",
wantRedirectLocationRegexp: "http://127.0.0.1:4242/callback" + `\?code=([^&]+)&scope=openid\+groups&state=` + happyDownstreamState,
wantRedirectLocationRegexp: "http://127.0.0.1:4242/callback" + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyDownstreamState,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
wantDownstreamIDTokenGroups: happyLDAPGroups,
@ -460,17 +465,18 @@ func TestPostLoginEndpoint(t *testing.T) {
map[string]string{"scope": "openid offline_access pinniped:request-audience"},
).Encode()
}),
formParams: happyUsernamePasswordFormParams,
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantBodyString: "",
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+pinniped%3Arequest-audience&state=` + happyDownstreamState,
formParams: happyUsernamePasswordFormParams,
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantBodyString: "",
// username and groups scopes were not requested but are granted anyway for the pinniped-cli client for backwards compatibility
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+pinniped%3Arequest-audience\+username\+groups&state=` + happyDownstreamState,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
wantDownstreamIDTokenGroups: happyLDAPGroups,
wantDownstreamRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"},
wantDownstreamRedirectURI: downstreamRedirectURI,
wantDownstreamGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"},
wantDownstreamGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience", "username", "groups"},
wantDownstreamNonce: downstreamNonce,
wantDownstreamClient: downstreamPinnipedCLIClientID,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
@ -478,7 +484,7 @@ func TestPostLoginEndpoint(t *testing.T) {
wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession,
},
{
name: "happy LDAP login when there are additional allowed downstream requested scopes with dynamic client",
name: "happy LDAP login when there are additional allowed downstream requested scopes with dynamic client, when dynamic client is allowed to request username and groups but does not request them",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources,
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
@ -492,8 +498,8 @@ func TestPostLoginEndpoint(t *testing.T) {
wantBodyString: "",
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+pinniped%3Arequest-audience&state=` + happyDownstreamState,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
wantDownstreamIDTokenGroups: happyLDAPGroups,
wantDownstreamIDTokenUsername: "", // username scope was not requested, so there should be no username in the ID token
wantDownstreamIDTokenGroups: []string{}, // groups scope was not requested, so there should be no groups in the ID token
wantDownstreamRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"},
wantDownstreamRedirectURI: downstreamRedirectURI,
wantDownstreamGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"},
@ -503,6 +509,74 @@ func TestPostLoginEndpoint(t *testing.T) {
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession,
},
{
name: "happy LDAP login when there are additional allowed downstream requested scopes with dynamic client, when dynamic client is not allowed to request username and does not request username",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) {
oidcClient, secret := testutil.OIDCClientAndStorageSecret(t,
"some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID,
[]configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude username scope)
[]configv1alpha1.Scope{"openid", "offline_access", "groups"}, // username not allowed
downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate)
require.NoError(t, supervisorClient.Tracker().Add(oidcClient))
require.NoError(t, kubeClient.Tracker().Add(secret))
},
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient,
map[string]string{"scope": "openid groups offline_access"},
).Encode()
}),
formParams: happyUsernamePasswordFormParams,
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantBodyString: "",
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+groups&state=` + happyDownstreamState,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
wantDownstreamIDTokenUsername: "", // username scope was not requested, so there should be no username in the ID token
wantDownstreamIDTokenGroups: happyLDAPGroups,
wantDownstreamRequestedScopes: []string{"openid", "offline_access", "groups"},
wantDownstreamRedirectURI: downstreamRedirectURI,
wantDownstreamGrantedScopes: []string{"openid", "offline_access", "groups"},
wantDownstreamNonce: downstreamNonce,
wantDownstreamClient: downstreamDynamicClientID,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession,
},
{
name: "happy LDAP login when there are additional allowed downstream requested scopes with dynamic client, when dynamic client is not allowed to request groups and does not request groups",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) {
oidcClient, secret := testutil.OIDCClientAndStorageSecret(t,
"some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID,
[]configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude groups scope)
[]configv1alpha1.Scope{"openid", "offline_access", "username"}, // groups not allowed
downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate)
require.NoError(t, supervisorClient.Tracker().Add(oidcClient))
require.NoError(t, kubeClient.Tracker().Add(secret))
},
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient,
map[string]string{"scope": "openid username offline_access"},
).Encode()
}),
formParams: happyUsernamePasswordFormParams,
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantBodyString: "",
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+username&state=` + happyDownstreamState,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
wantDownstreamIDTokenGroups: []string{}, // groups scope was not requested, so there should be no groups in the ID token
wantDownstreamRequestedScopes: []string{"openid", "offline_access", "username"},
wantDownstreamRedirectURI: downstreamRedirectURI,
wantDownstreamGrantedScopes: []string{"openid", "offline_access", "username"},
wantDownstreamNonce: downstreamNonce,
wantDownstreamClient: downstreamDynamicClientID,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession,
},
{
name: "happy LDAP when downstream OIDC validations are skipped because the openid scope was not requested",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
@ -515,17 +589,18 @@ func TestPostLoginEndpoint(t *testing.T) {
},
).Encode()
}),
formParams: happyUsernamePasswordFormParams,
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantBodyString: "",
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=&state=` + happyDownstreamState, // no scopes granted
formParams: happyUsernamePasswordFormParams,
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantBodyString: "",
// username and groups scopes were not requested but are granted anyway for the pinniped-cli client for backwards compatibility
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=username\+groups&state=` + happyDownstreamState,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
wantDownstreamIDTokenGroups: happyLDAPGroups,
wantDownstreamRequestedScopes: []string{"email"}, // only email was requested
wantDownstreamRedirectURI: downstreamRedirectURI,
wantDownstreamGrantedScopes: []string{}, // no scopes granted
wantDownstreamGrantedScopes: []string{"username", "groups"},
wantDownstreamNonce: downstreamNonce,
wantDownstreamClient: downstreamPinnipedCLIClientID,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
@ -533,7 +608,7 @@ func TestPostLoginEndpoint(t *testing.T) {
wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession,
},
{
name: "happy LDAP login when groups scope is not requested",
name: "happy LDAP login when username and groups scopes are not requested",
idps: oidctestutil.NewUpstreamIDPListerBuilder().
WithLDAP(&upstreamLDAPIdentityProvider). // should pick this one
WithActiveDirectory(&erroringUpstreamLDAPIdentityProvider),
@ -542,16 +617,18 @@ func TestPostLoginEndpoint(t *testing.T) {
map[string]string{"scope": "openid"},
).Encode()
}),
formParams: happyUsernamePasswordFormParams,
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantBodyString: "",
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid&state=` + happyDownstreamState,
formParams: happyUsernamePasswordFormParams,
wantStatus: http.StatusSeeOther,
wantContentType: htmlContentType,
wantBodyString: "",
// username and groups scopes were not requested but are granted anyway for the pinniped-cli client for backwards compatibility
wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyDownstreamState,
wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID,
wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator,
wantDownstreamIDTokenGroups: happyLDAPGroups,
wantDownstreamRequestedScopes: []string{"openid"},
wantDownstreamRedirectURI: downstreamRedirectURI,
wantDownstreamGrantedScopes: []string{"openid"},
wantDownstreamGrantedScopes: []string{"openid", "username", "groups"},
wantDownstreamNonce: downstreamNonce,
wantDownstreamClient: downstreamPinnipedCLIClientID,
wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
@ -810,6 +887,46 @@ func TestPostLoginEndpoint(t *testing.T) {
formParams: happyUsernamePasswordFormParams,
wantErr: "error using state downstream auth params",
},
{
name: "using dynamic client which is not allowed to request username scope in authorize request but requests it anyway",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) {
oidcClient, secret := testutil.OIDCClientAndStorageSecret(t,
"some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID,
[]configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude username scope)
[]configv1alpha1.Scope{"openid", "offline_access", "groups"}, // username not allowed
downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate)
require.NoError(t, supervisorClient.Tracker().Add(oidcClient))
require.NoError(t, kubeClient.Tracker().Add(secret))
},
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient,
map[string]string{"scope": "openid username offline_access"},
).Encode()
}),
formParams: happyUsernamePasswordFormParams,
wantErr: "error using state downstream auth params",
},
{
name: "using dynamic client which is not allowed to request groups scope in authorize request but requests it anyway",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) {
oidcClient, secret := testutil.OIDCClientAndStorageSecret(t,
"some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID,
[]configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude groups scope)
[]configv1alpha1.Scope{"openid", "offline_access", "username"}, // groups not allowed
downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}, oidcclientvalidator.Validate)
require.NoError(t, supervisorClient.Tracker().Add(oidcClient))
require.NoError(t, kubeClient.Tracker().Add(secret))
},
decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) {
data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient,
map[string]string{"scope": "openid groups offline_access"},
).Encode()
}),
formParams: happyUsernamePasswordFormParams,
wantErr: "error using state downstream auth params",
},
{
name: "downstream scopes do not match what is configured for client with dynamic client",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),

View File

@ -11,13 +11,13 @@ import (
"net/http"
"time"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/felixge/httpsnoop"
"github.com/ory/fosite"
"github.com/ory/fosite/compose"
errorsx "github.com/pkg/errors"
"go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/oidc/csrftoken"
"go.pinniped.dev/internal/oidc/jwks"
@ -40,16 +40,17 @@ const (
)
const (
// Just in case we need to make a breaking change to the format of the upstream state param,
// we are including a format version number. This gives the opportunity for a future version of Pinniped
// to have the consumer of this format decide to reject versions that it doesn't understand.
// UpstreamStateParamFormatVersion exists just in case we need to make a breaking change to the format of the
// upstream state param, we are including a format version number. This gives the opportunity for a future version
// of Pinniped to have the consumer of this format decide to reject versions that it doesn't understand.
//
// Version 1 was the original version.
// Version 2 added the UpstreamType field to the UpstreamStateParamData struct.
UpstreamStateParamFormatVersion = "2"
// The `name` passed to the encoder for encoding the upstream state param value. This name is short
// because it will be encoded into the upstream state param value and we're trying to keep that small.
// UpstreamStateParamEncodingName is the `name` passed to the encoder for encoding the upstream state param value.
// This name is short because it will be encoded into the upstream state param value, and we're trying to keep that
// small.
UpstreamStateParamEncodingName = "s"
// CSRFCookieName is the name of the browser cookie which shall hold our CSRF value.
@ -61,29 +62,6 @@ const (
// cookie contents.
CSRFCookieEncodingName = "csrf"
// The name of the issuer claim specified in the OIDC spec.
IDTokenIssuerClaim = "iss"
// The name of the subject claim specified in the OIDC spec.
IDTokenSubjectClaim = "sub"
// DownstreamUsernameClaim is a custom claim in the downstream ID token
// whose value is mapped from a claim in the upstream token.
// By default the value is the same as the downstream subject claim's.
DownstreamUsernameClaim = "username"
// DownstreamGroupsClaim is what we will use to encode the groups in the downstream OIDC ID token
// information.
DownstreamGroupsClaim = "groups"
// DownstreamGroupsScope is a custom scope that determines whether the
// groups claim will be returned in ID tokens.
DownstreamGroupsScope = "groups"
// RequestAudienceScope is a custom scope that determines whether a RFC8693 token
// exchange is allowed to request a different audience.
RequestAudienceScope = "pinniped:request-audience"
// CSRFCookieLifespan is the length of time that the CSRF cookie is valid. After this time, the
// Supervisor's authorization endpoint should give the browser a new CSRF cookie. We set it to
// a week so that it is unlikely to expire during a login.
@ -229,7 +207,7 @@ func FositeOauth2Helper(
EnforcePKCE: true,
// "offline_access" as per https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
RefreshTokenScopes: []string{coreosoidc.ScopeOfflineAccess},
RefreshTokenScopes: []string{oidcapi.ScopeOfflineAccess},
// The default is to support all prompt values from the spec.
// See https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest

View File

@ -7,11 +7,11 @@ import (
"fmt"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/crypto/bcrypt"
v1 "k8s.io/api/core/v1"
"go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/oidcclientsecretstorage"
)
@ -27,16 +27,6 @@ const (
reasonNoClientSecretFound = "NoClientSecretFound"
reasonInvalidClientSecretFound = "InvalidClientSecretFound"
authorizationCodeGrantTypeName = "authorization_code"
refreshTokenGrantTypeName = "refresh_token"
tokenExchangeGrantTypeName = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential
openidScopeName = oidc.ScopeOpenID
offlineAccessScopeName = oidc.ScopeOfflineAccess
requestAudienceScopeName = "pinniped:request-audience"
usernameScopeName = "username"
groupsScopeName = "groups"
allowedGrantTypesFieldName = "allowedGrantTypes"
allowedScopesFieldName = "allowedScopes"
)
@ -67,21 +57,21 @@ func Validate(oidcClient *v1alpha1.OIDCClient, secret *v1.Secret, minBcryptCost
func validateAllowedScopes(oidcClient *v1alpha1.OIDCClient, conditions []*v1alpha1.Condition) []*v1alpha1.Condition {
m := make([]string, 0, 4)
if !allowedScopesContains(oidcClient, openidScopeName) {
m = append(m, fmt.Sprintf("%q must always be included in %q", openidScopeName, allowedScopesFieldName))
if !allowedScopesContains(oidcClient, oidcapi.ScopeOpenID) {
m = append(m, fmt.Sprintf("%q must always be included in %q", oidcapi.ScopeOpenID, allowedScopesFieldName))
}
if allowedGrantTypesContains(oidcClient, refreshTokenGrantTypeName) && !allowedScopesContains(oidcClient, offlineAccessScopeName) {
if allowedGrantTypesContains(oidcClient, oidcapi.GrantTypeRefreshToken) && !allowedScopesContains(oidcClient, oidcapi.ScopeOfflineAccess) {
m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q",
offlineAccessScopeName, allowedScopesFieldName, refreshTokenGrantTypeName, allowedGrantTypesFieldName))
oidcapi.ScopeOfflineAccess, allowedScopesFieldName, oidcapi.GrantTypeRefreshToken, allowedGrantTypesFieldName))
}
if allowedScopesContains(oidcClient, requestAudienceScopeName) &&
(!allowedScopesContains(oidcClient, usernameScopeName) || !allowedScopesContains(oidcClient, groupsScopeName)) {
if allowedScopesContains(oidcClient, oidcapi.ScopeRequestAudience) &&
(!allowedScopesContains(oidcClient, oidcapi.ScopeUsername) || !allowedScopesContains(oidcClient, oidcapi.ScopeGroups)) {
m = append(m, fmt.Sprintf("%q and %q must be included in %q when %q is included in %q",
usernameScopeName, groupsScopeName, allowedScopesFieldName, requestAudienceScopeName, allowedScopesFieldName))
oidcapi.ScopeUsername, oidcapi.ScopeGroups, allowedScopesFieldName, oidcapi.ScopeRequestAudience, allowedScopesFieldName))
}
if allowedGrantTypesContains(oidcClient, tokenExchangeGrantTypeName) && !allowedScopesContains(oidcClient, requestAudienceScopeName) {
if allowedGrantTypesContains(oidcClient, oidcapi.GrantTypeTokenExchange) && !allowedScopesContains(oidcClient, oidcapi.ScopeRequestAudience) {
m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q",
requestAudienceScopeName, allowedScopesFieldName, tokenExchangeGrantTypeName, allowedGrantTypesFieldName))
oidcapi.ScopeRequestAudience, allowedScopesFieldName, oidcapi.GrantTypeTokenExchange, allowedGrantTypesFieldName))
}
if len(m) == 0 {
@ -107,17 +97,17 @@ func validateAllowedScopes(oidcClient *v1alpha1.OIDCClient, conditions []*v1alph
func validateAllowedGrantTypes(oidcClient *v1alpha1.OIDCClient, conditions []*v1alpha1.Condition) []*v1alpha1.Condition {
m := make([]string, 0, 3)
if !allowedGrantTypesContains(oidcClient, authorizationCodeGrantTypeName) {
if !allowedGrantTypesContains(oidcClient, oidcapi.GrantTypeAuthorizationCode) {
m = append(m, fmt.Sprintf("%q must always be included in %q",
authorizationCodeGrantTypeName, allowedGrantTypesFieldName))
oidcapi.GrantTypeAuthorizationCode, allowedGrantTypesFieldName))
}
if allowedScopesContains(oidcClient, offlineAccessScopeName) && !allowedGrantTypesContains(oidcClient, refreshTokenGrantTypeName) {
if allowedScopesContains(oidcClient, oidcapi.ScopeOfflineAccess) && !allowedGrantTypesContains(oidcClient, oidcapi.GrantTypeRefreshToken) {
m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q",
refreshTokenGrantTypeName, allowedGrantTypesFieldName, offlineAccessScopeName, allowedScopesFieldName))
oidcapi.GrantTypeRefreshToken, allowedGrantTypesFieldName, oidcapi.ScopeOfflineAccess, allowedScopesFieldName))
}
if allowedScopesContains(oidcClient, requestAudienceScopeName) && !allowedGrantTypesContains(oidcClient, tokenExchangeGrantTypeName) {
if allowedScopesContains(oidcClient, oidcapi.ScopeRequestAudience) && !allowedGrantTypesContains(oidcClient, oidcapi.GrantTypeTokenExchange) {
m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q",
tokenExchangeGrantTypeName, allowedGrantTypesFieldName, requestAudienceScopeName, allowedScopesFieldName))
oidcapi.GrantTypeTokenExchange, allowedGrantTypesFieldName, oidcapi.ScopeRequestAudience, allowedScopesFieldName))
}
if len(m) == 0 {

View File

@ -171,7 +171,7 @@ func TestManager(t *testing.T) {
r.NoError(err)
actualLocationQueryParams := parsedLocation.Query()
r.Contains(actualLocationQueryParams, "code")
r.Equal("openid", actualLocationQueryParams.Get("scope"))
r.Equal("openid username groups", actualLocationQueryParams.Get("scope"))
r.Equal("some-state-value-with-enough-bytes-to-exceed-min-allowed", actualLocationQueryParams.Get("state"))
// Make sure that we wired up the callback endpoint to use kube storage for fosite sessions.
@ -343,7 +343,7 @@ func TestManager(t *testing.T) {
authRequestParams := "?" + url.Values{
"response_type": []string{"code"},
"scope": []string{"openid profile email"},
"scope": []string{"openid profile email username groups"},
"client_id": []string{downstreamClientID},
"state": []string{"some-state-value-with-enough-bytes-to-exceed-min-allowed"},
"nonce": []string{"some-nonce-value-with-enough-bytes-to-exceed-min-allowed"},

View File

@ -17,6 +17,7 @@ import (
"k8s.io/apiserver/pkg/warning"
"k8s.io/utils/strings/slices"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/downstreamsession"
@ -39,7 +40,7 @@ func NewHandler(
}
// Check if we are performing a refresh grant.
if accessRequest.GetGrantTypes().ExactOne("refresh_token") {
if accessRequest.GetGrantTypes().ExactOne(oidcapi.GrantTypeRefreshToken) {
// The above call to NewAccessRequest has loaded the session from storage into the accessRequest variable.
// The session, requested scopes, and requested audience from the original authorize request was retrieved
// from the Kube storage layer and added to the accessRequest. Additionally, the audience and scopes may
@ -54,7 +55,7 @@ func NewHandler(
// When we are in the authorization code flow, check if we have any warnings that previous handlers want us
// to send to the client to be printed on the CLI.
if accessRequest.GetGrantTypes().ExactOne("authorization_code") {
if accessRequest.GetGrantTypes().ExactOne(oidcapi.GrantTypeAuthorizationCode) {
storedSession := accessRequest.GetSession().(*psession.PinnipedSession)
customSessionData := storedSession.Custom
if customSessionData != nil {
@ -108,20 +109,27 @@ func upstreamRefresh(ctx context.Context, accessRequest fosite.AccessRequester,
}
grantedScopes := accessRequest.GetGrantedScopes()
clientID := accessRequest.GetClient().GetID()
switch customSessionData.ProviderType {
case psession.ProviderTypeOIDC:
return upstreamOIDCRefresh(ctx, session, providerCache, grantedScopes)
return upstreamOIDCRefresh(ctx, session, providerCache, grantedScopes, clientID)
case psession.ProviderTypeLDAP:
return upstreamLDAPRefresh(ctx, providerCache, session, grantedScopes)
return upstreamLDAPRefresh(ctx, providerCache, session, grantedScopes, clientID)
case psession.ProviderTypeActiveDirectory:
return upstreamLDAPRefresh(ctx, providerCache, session, grantedScopes)
return upstreamLDAPRefresh(ctx, providerCache, session, grantedScopes, clientID)
default:
return errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
}
func upstreamOIDCRefresh(ctx context.Context, session *psession.PinnipedSession, providerCache oidc.UpstreamIdentityProvidersLister, grantedScopes []string) error {
func upstreamOIDCRefresh(
ctx context.Context,
session *psession.PinnipedSession,
providerCache oidc.UpstreamIdentityProvidersLister,
grantedScopes []string,
clientID string,
) error {
s := session.Custom
if s.OIDC == nil {
return errorsx.WithStack(errMissingUpstreamSessionInternalError())
@ -180,7 +188,7 @@ func upstreamOIDCRefresh(ctx context.Context, session *psession.PinnipedSession,
return err
}
groupsScope := slices.Contains(grantedScopes, oidc.DownstreamGroupsScope)
groupsScope := slices.Contains(grantedScopes, oidcapi.ScopeGroups)
if groupsScope { //nolint:nestif
// If possible, update the user's group memberships. The configured groups claim name (if there is one) may or
// may not be included in the newly fetched and merged claims. It could be missing due to a misconfiguration of the
@ -204,8 +212,8 @@ func upstreamOIDCRefresh(ctx context.Context, session *psession.PinnipedSession,
if err != nil {
return err
}
warnIfGroupsChanged(ctx, oldGroups, refreshedGroups, username)
session.Fosite.Claims.Extra[oidc.DownstreamGroupsClaim] = refreshedGroups
warnIfGroupsChanged(ctx, oldGroups, refreshedGroups, username, clientID)
session.Fosite.Claims.Extra[oidcapi.IDTokenClaimGroups] = refreshedGroups
}
}
@ -240,7 +248,7 @@ func validateIdentityUnchangedSinceInitialLogin(mergedClaims map[string]interfac
return nil
}
newSub, hasSub := getString(mergedClaims, oidc.IDTokenSubjectClaim)
newSub, hasSub := getString(mergedClaims, oidcapi.IDTokenClaimSubject)
if !hasSub {
return errUpstreamRefreshError().WithHintf(
"Upstream refresh failed.").WithTrace(errors.New("subject in upstream refresh not found")).
@ -253,7 +261,10 @@ func validateIdentityUnchangedSinceInitialLogin(mergedClaims map[string]interfac
}
newUsername, hasUsername := getString(mergedClaims, usernameClaimName)
oldUsername := session.Fosite.Claims.Extra[oidc.DownstreamUsernameClaim]
oldUsername, err := getDownstreamUsernameFromPinnipedSession(session)
if err != nil {
return err
}
// It's possible that a username wasn't returned by the upstream provider during refresh,
// but if it is, verify that it hasn't changed.
if hasUsername && oldUsername != newUsername {
@ -262,7 +273,7 @@ func validateIdentityUnchangedSinceInitialLogin(mergedClaims map[string]interfac
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)
}
newIssuer, hasIssuer := getString(mergedClaims, oidc.IDTokenIssuerClaim)
newIssuer, hasIssuer := getString(mergedClaims, oidcapi.IDTokenClaimIssuer)
// It's possible that an issuer wasn't returned by the upstream provider during refresh,
// but if it is, verify that it hasn't changed.
if hasIssuer && s.OIDC.UpstreamIssuer != newIssuer {
@ -297,14 +308,20 @@ func findOIDCProviderByNameAndValidateUID(
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
}
func upstreamLDAPRefresh(ctx context.Context, providerCache oidc.UpstreamIdentityProvidersLister, session *psession.PinnipedSession, grantedScopes []string) error {
func upstreamLDAPRefresh(
ctx context.Context,
providerCache oidc.UpstreamIdentityProvidersLister,
session *psession.PinnipedSession,
grantedScopes []string,
clientID string,
) error {
username, err := getDownstreamUsernameFromPinnipedSession(session)
if err != nil {
return err
}
subject := session.Fosite.Claims.Subject
var oldGroups []string
if slices.Contains(grantedScopes, oidc.DownstreamGroupsScope) {
if slices.Contains(grantedScopes, oidcapi.ScopeGroups) {
oldGroups, err = getDownstreamGroupsFromPinnipedSession(session)
if err != nil {
return err
@ -349,12 +366,11 @@ func upstreamLDAPRefresh(ctx context.Context, providerCache oidc.UpstreamIdentit
"Upstream refresh failed.").WithTrace(err).
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)
}
groupsScope := slices.Contains(grantedScopes, oidc.DownstreamGroupsScope)
groupsScope := slices.Contains(grantedScopes, oidcapi.ScopeGroups)
if groupsScope {
warnIfGroupsChanged(ctx, oldGroups, groups, username, clientID)
// Replace the old value with the new value.
session.Fosite.Claims.Extra[oidc.DownstreamGroupsClaim] = groups
warnIfGroupsChanged(ctx, oldGroups, groups, username)
session.Fosite.Claims.Extra[oidcapi.IDTokenClaimGroups] = groups
}
return nil
@ -391,16 +407,8 @@ func findLDAPProviderByNameAndValidateUID(
}
func getDownstreamUsernameFromPinnipedSession(session *psession.PinnipedSession) (string, error) {
extra := session.Fosite.Claims.Extra
if extra == nil {
return "", errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
downstreamUsernameInterface := extra[oidc.DownstreamUsernameClaim]
if downstreamUsernameInterface == nil {
return "", errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
downstreamUsername, ok := downstreamUsernameInterface.(string)
if !ok || len(downstreamUsername) == 0 {
downstreamUsername := session.Custom.Username
if len(downstreamUsername) == 0 {
return "", errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
return downstreamUsername, nil
@ -411,7 +419,7 @@ func getDownstreamGroupsFromPinnipedSession(session *psession.PinnipedSession) (
if extra == nil {
return nil, errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
downstreamGroupsInterface := extra[oidc.DownstreamGroupsClaim]
downstreamGroupsInterface := extra[oidcapi.IDTokenClaimGroups]
if downstreamGroupsInterface == nil {
return nil, errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
@ -431,8 +439,17 @@ func getDownstreamGroupsFromPinnipedSession(session *psession.PinnipedSession) (
return downstreamGroups, nil
}
func warnIfGroupsChanged(ctx context.Context, oldGroups, newGroups []string, username string) {
func warnIfGroupsChanged(ctx context.Context, oldGroups, newGroups []string, username string, clientID string) {
if clientID != oidcapi.ClientIDPinnipedCLI {
// Only send these warnings to the CLI client. They are intended for kubectl to print to the screen.
// A webapp using a dynamic client wouldn't know to look for these special warning headers, and
// if the dynamic client lacked the username scope, then these warning messages would be leaking
// the user's username to the client within the text of the warning.
return
}
added, removed := diffSortedGroups(oldGroups, newGroups)
if len(added) > 0 {
warning.AddWarning(ctx, "", fmt.Sprintf("User %q has been added to the following groups: %q", username, added))
}

File diff suppressed because it is too large Load Diff

View File

@ -8,21 +8,19 @@ import (
"net/url"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/ory/fosite"
"github.com/ory/fosite/compose"
"github.com/ory/fosite/handler/oauth2"
"github.com/ory/fosite/handler/openid"
"github.com/pkg/errors"
"go.pinniped.dev/internal/oidc/clientregistry"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/psession"
)
const (
tokenExchangeGrantType = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint: gosec
tokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token" //nolint: gosec
tokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" //nolint: gosec
pinnipedTokenExchangeScope = "pinniped:request-audience" //nolint: gosec
tokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token" //nolint: gosec
tokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" //nolint: gosec
)
type stsParams struct {
@ -78,17 +76,22 @@ func (t *TokenExchangeHandler) PopulateTokenEndpointResponse(ctx context.Context
}
// Check that the client is allowed to perform this grant type.
if !requester.GetClient().GetGrantTypes().Has(tokenExchangeGrantType) {
if !requester.GetClient().GetGrantTypes().Has(oidcapi.GrantTypeTokenExchange) {
// This error message is trying to be similar to the analogous one in fosite's flow_authorize_code_token.go.
return errors.WithStack(fosite.ErrUnauthorizedClient.WithHintf(`The OAuth 2.0 Client is not allowed to use token exchange grant "%s".`, tokenExchangeGrantType))
return errors.WithStack(fosite.ErrUnauthorizedClient.WithHintf(`The OAuth 2.0 Client is not allowed to use token exchange grant "%s".`, oidcapi.GrantTypeTokenExchange))
}
// Require that the incoming access token has the pinniped:request-audience and OpenID scopes.
if !originalRequester.GetGrantedScopes().Has(pinnipedTokenExchangeScope) {
return errors.WithStack(fosite.ErrAccessDenied.WithHintf("missing the %q scope", pinnipedTokenExchangeScope))
if !originalRequester.GetGrantedScopes().Has(oidcapi.ScopeRequestAudience) {
return errors.WithStack(fosite.ErrAccessDenied.WithHintf("missing the %q scope", oidcapi.ScopeRequestAudience))
}
if !originalRequester.GetGrantedScopes().Has(oidc.ScopeOpenID) {
return errors.WithStack(fosite.ErrAccessDenied.WithHintf("missing the %q scope", oidc.ScopeOpenID))
if !originalRequester.GetGrantedScopes().Has(oidcapi.ScopeOpenID) {
return errors.WithStack(fosite.ErrAccessDenied.WithHintf("missing the %q scope", oidcapi.ScopeOpenID))
}
// Check that the stored session meets the minimum requirements for token exchange.
if err := t.validateSession(originalRequester); err != nil {
return errors.WithStack(err)
}
// Use the original authorize request information, along with the requested audience, to mint a new JWT.
@ -110,6 +113,22 @@ func (t *TokenExchangeHandler) mintJWT(ctx context.Context, requester fosite.Req
return t.idTokenStrategy.GenerateIDToken(ctx, downscoped)
}
func (t *TokenExchangeHandler) validateSession(requester fosite.Requester) error {
pSession, ok := requester.GetSession().(*psession.PinnipedSession)
if !ok {
// This shouldn't really happen.
return fosite.ErrServerError.WithHint("Invalid session storage.")
}
username, ok := pSession.IDTokenClaims().Extra[oidcapi.IDTokenClaimUsername].(string)
if !ok || username == "" {
// No username was stored in the session's ID token claims (or the stored username was not a string, which
// shouldn't really happen). Usernames will not be stored in the session's ID token claims when the username
// scope was not requested/granted, but otherwise they should be stored.
return fosite.ErrAccessDenied.WithHintf("No username found in session. Ensure that the %q scope was requested and granted at the authorization endpoint.", oidcapi.ScopeUsername)
}
return nil
}
func (t *TokenExchangeHandler) validateParams(params url.Values) (*stsParams, error) {
var result stsParams
@ -157,8 +176,8 @@ func (t *TokenExchangeHandler) validateParams(params url.Values) (*stsParams, er
if strings.Contains(result.requestedAudience, ".pinniped.dev") {
return nil, fosite.ErrInvalidRequest.WithHintf("requested audience cannot contain '.pinniped.dev'")
}
if result.requestedAudience == clientregistry.PinnipedCLIClientID {
return nil, fosite.ErrInvalidRequest.WithHintf("requested audience cannot equal '%s'", clientregistry.PinnipedCLIClientID)
if result.requestedAudience == oidcapi.ClientIDPinnipedCLI {
return nil, fosite.ErrInvalidRequest.WithHintf("requested audience cannot equal '%s'", oidcapi.ClientIDPinnipedCLI)
}
return &result, nil
@ -181,5 +200,5 @@ func (t *TokenExchangeHandler) CanSkipClientAuth(_ fosite.AccessRequester) bool
}
func (t *TokenExchangeHandler) CanHandleTokenEndpointRequest(requester fosite.AccessRequester) bool {
return requester.GetGrantTypes().ExactOne(tokenExchangeGrantType)
return requester.GetGrantTypes().ExactOne(oidcapi.GrantTypeTokenExchange)
}

View File

@ -27,6 +27,11 @@ var _ openid.Session = &PinnipedSession{}
// CustomSessionData is the custom session data needed by Pinniped. It should be treated as a union type,
// where the value of ProviderType decides which other fields to use.
type CustomSessionData struct {
// Username will contain the downstream username determined during initial authorization. We store this
// so that we can validate that it does not change upon refresh. This should normally never be empty, since
// all users must have a username.
Username string `json:"username"`
// The Kubernetes resource UID of the identity provider CRD for the upstream IDP used to start this session.
// This should be validated again upon downstream refresh to make sure that we are not refreshing against
// a different identity provider CRD which just happens to have the same name.

View File

@ -7,6 +7,8 @@ import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
@ -41,19 +43,30 @@ func allDynamicClientScopes() []configv1alpha1.Scope {
return scopes
}
// fullyCapableOIDCClient returns an OIDC client which is allowed to use all grant types and all scopes that
// are supported by the Supervisor for dynamic clients.
func fullyCapableOIDCClient(namespace string, clientID string, clientUID string, redirectURI string) *configv1alpha1.OIDCClient {
func newOIDCClient(
namespace string,
clientID string,
clientUID string,
redirectURI string,
allowedGrantTypes []configv1alpha1.GrantType,
allowedScopes []configv1alpha1.Scope,
) *configv1alpha1.OIDCClient {
return &configv1alpha1.OIDCClient{
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: clientID, Generation: 1, UID: types.UID(clientUID)},
Spec: configv1alpha1.OIDCClientSpec{
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"},
AllowedScopes: allDynamicClientScopes(),
AllowedGrantTypes: allowedGrantTypes,
AllowedScopes: allowedScopes,
AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(redirectURI)},
},
}
}
// OIDCClientValidatorFunc is an interface-like type that allows these test helpers to avoid having a direct dependency
// on the production code, to avoid circular module dependencies. Implemented by oidcclientvalidator.Validate.
type OIDCClientValidatorFunc func(oidcClient *configv1alpha1.OIDCClient, secret *corev1.Secret, minBcryptCost int) (bool, []*configv1alpha1.Condition, []string)
// FullyCapableOIDCClientAndStorageSecret returns an OIDC client which is allowed to use all grant types and all scopes
// that are supported by the Supervisor for dynamic clients, along with a corresponding client secret storage Secret.
func FullyCapableOIDCClientAndStorageSecret(
t *testing.T,
namespace string,
@ -61,7 +74,38 @@ func FullyCapableOIDCClientAndStorageSecret(
clientUID string,
redirectURI string,
hashes []string,
validateFunc OIDCClientValidatorFunc,
) (*configv1alpha1.OIDCClient, *corev1.Secret) {
return fullyCapableOIDCClient(namespace, clientID, clientUID, redirectURI),
OIDCClientSecretStorageSecretForUID(t, namespace, clientUID, hashes)
allScopes := allDynamicClientScopes()
allGrantTypes := []configv1alpha1.GrantType{
"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token",
}
return OIDCClientAndStorageSecret(t, namespace, clientID, clientUID, allGrantTypes, allScopes, redirectURI, hashes, validateFunc)
}
// OIDCClientAndStorageSecret returns an OIDC client which is allowed to use the specified grant types and scopes,
// along with a corresponding client secret storage Secret. It also validates the client to make sure that the specified
// combination of grant types and scopes is considered valid before returning the client.
func OIDCClientAndStorageSecret(
t *testing.T,
namespace string,
clientID string,
clientUID string,
allowedGrantTypes []configv1alpha1.GrantType,
allowedScopes []configv1alpha1.Scope,
redirectURI string,
hashes []string,
validateFunc OIDCClientValidatorFunc,
) (*configv1alpha1.OIDCClient, *corev1.Secret) {
oidcClient := newOIDCClient(namespace, clientID, clientUID, redirectURI, allowedGrantTypes, allowedScopes)
secret := OIDCClientSecretStorageSecretForUID(t, namespace, clientUID, hashes)
// If a test made an invalid OIDCClient then inform the author of the test, so they can fix the test case.
// This is an easy mistake to make when writing tests because there are lots of validations on OIDCClients.
valid, conditions, _ := validateFunc(oidcClient, secret, bcrypt.MinCost)
require.True(t, valid, "Test's OIDCClient should have been valid. See conditions for errors: %s", conditions)
return oidcClient, secret
}

View File

@ -1062,19 +1062,31 @@ func validateAuthcodeStorage(
// Now confirm the ID token claims.
actualClaims := storedSessionFromAuthcode.Fosite.Claims
// Should always have an azp claim.
require.Equal(t, wantDownstreamClientID, actualClaims.Extra["azp"])
wantDownstreamIDTokenExtraClaimsCount := 1 // should always have azp claim
// Check the user's identity, which are put into the downstream ID token's subject, username and groups claims.
require.Equal(t, wantDownstreamIDTokenSubject, actualClaims.Subject)
require.Equal(t, wantDownstreamIDTokenUsername, actualClaims.Extra["username"])
if wantDownstreamIDTokenUsername == "" {
require.NotContains(t, actualClaims.Extra, "username")
} else {
wantDownstreamIDTokenExtraClaimsCount++ // should also have username claim
require.Equal(t, wantDownstreamIDTokenUsername, actualClaims.Extra["username"])
}
if slices.Contains(wantDownstreamGrantedScopes, "groups") {
require.Len(t, actualClaims.Extra, 2)
wantDownstreamIDTokenExtraClaimsCount++ // should also have groups claim
actualDownstreamIDTokenGroups := actualClaims.Extra["groups"]
require.NotNil(t, actualDownstreamIDTokenGroups)
require.ElementsMatch(t, wantDownstreamIDTokenGroups, actualDownstreamIDTokenGroups)
} else {
require.Len(t, actualClaims.Extra, 1)
require.Emptyf(t, wantDownstreamIDTokenGroups, "test case did not want the groups scope to be granted, "+
"but wanted something in the groups claim, which doesn't make sense. please review the test case's expectations.")
actualDownstreamIDTokenGroups := actualClaims.Extra["groups"]
require.Nil(t, actualDownstreamIDTokenGroups)
}
// Make sure that we asserted on every extra claim.
require.Len(t, actualClaims.Extra, wantDownstreamIDTokenExtraClaimsCount)
// Check the rest of the downstream ID token's claims. Fosite wants us to set these (in UTC time).
testutil.RequireTimeInDelta(t, time.Now().UTC(), actualClaims.RequestedAt, timeComparisonFudgeFactor)

View File

@ -24,6 +24,7 @@ func NewFakePinnipedSession() *psession.PinnipedSession {
Subject: "panda",
},
Custom: &psession.CustomSessionData{
Username: "fake-username",
ProviderUID: "fake-provider-uid",
ProviderType: "fake-provider-type",
ProviderName: "fake-provider-name",

View File

@ -23,10 +23,10 @@ import (
"k8s.io/utils/strings/slices"
"k8s.io/utils/trace"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/authenticators"
"go.pinniped.dev/internal/crypto/ptls"
"go.pinniped.dev/internal/endpointaddr"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/downstreamsession"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/plog"
@ -241,7 +241,7 @@ func (p *Provider) PerformRefresh(ctx context.Context, storedRefreshAttributes p
return storedRefreshAttributes.Groups, nil
}
// if we were not granted the groups scope, we should not search for groups or return any.
if !slices.Contains(storedRefreshAttributes.GrantedScopes, oidc.DownstreamGroupsScope) {
if !slices.Contains(storedRefreshAttributes.GrantedScopes, oidcapi.ScopeGroups) {
return nil, nil
}
@ -593,7 +593,7 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, grantedScopes [
}
var mappedGroupNames []string
if slices.Contains(grantedScopes, oidc.DownstreamGroupsScope) {
if slices.Contains(grantedScopes, oidcapi.ScopeGroups) {
mappedGroupNames, err = p.searchGroupsForUserDN(conn, userEntry.DN)
if err != nil {
return nil, err

View File

@ -20,8 +20,8 @@ import (
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/sets"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/pkg/oidcclient/nonce"
@ -286,7 +286,7 @@ func (p *ProviderConfig) ValidateTokenAndMergeWithUserInfo(ctx context.Context,
return nil, err
}
idTokenSubject, _ := validatedClaims[oidc.IDTokenSubjectClaim].(string)
idTokenSubject, _ := validatedClaims[oidcapi.IDTokenClaimSubject].(string)
if len(idTokenSubject) > 0 || !requireIDToken {
// only fetch userinfo if the ID token has a subject or if we are ignoring the id token completely.
@ -346,7 +346,7 @@ func (p *ProviderConfig) validateIDToken(ctx context.Context, tok *oauth2.Token,
}
func (p *ProviderConfig) maybeFetchUserInfoAndMergeClaims(ctx context.Context, tok *oauth2.Token, claims map[string]interface{}, requireIDToken bool, requireUserInfo bool) error {
idTokenSubject, _ := claims[oidc.IDTokenSubjectClaim].(string)
idTokenSubject, _ := claims[oidcapi.IDTokenClaimSubject].(string)
userInfo, err := p.maybeFetchUserInfo(ctx, tok, requireUserInfo)
if err != nil {
@ -371,7 +371,7 @@ func (p *ProviderConfig) maybeFetchUserInfoAndMergeClaims(ctx context.Context, t
}
// keep track of the issuer from the ID token
idTokenIssuer := claims["iss"]
idTokenIssuer := claims[oidcapi.IDTokenClaimIssuer]
// merge existing claims with user info claims
if err := userInfo.Claims(&claims); err != nil {
@ -381,9 +381,9 @@ func (p *ProviderConfig) maybeFetchUserInfoAndMergeClaims(ctx context.Context, t
// "If signed, the UserInfo Response SHOULD contain the Claims iss (issuer) and aud (audience) as members. The iss value SHOULD be the OP's Issuer Identifier URL."
// See https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
// So we just ignore it and use it the version from the id token, which has stronger guarantees.
delete(claims, "iss")
delete(claims, oidcapi.IDTokenClaimIssuer)
if idTokenIssuer != nil {
claims["iss"] = idTokenIssuer
claims[oidcapi.IDTokenClaimIssuer] = idTokenIssuer
}
maybeLogClaims("claims from ID token and userinfo", p.Name, claims)

View File

@ -21,14 +21,14 @@ import (
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/go-logr/logr"
"github.com/pkg/browser"
"golang.org/x/oauth2"
"golang.org/x/term"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
supervisoroidc "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/httputil/securityheader"
"go.pinniped.dev/internal/net/phttp"
@ -91,7 +91,7 @@ type handlerState struct {
callbackPath string
// Generated parameters of a login flow.
provider *oidc.Provider
provider *coreosoidc.Provider
oauth2Config *oauth2.Config
useFormPost bool
state state.State
@ -106,8 +106,8 @@ type handlerState struct {
getEnv func(key string) string
listen func(string, string) (net.Listener, error)
isTTY func(int) bool
getProvider func(*oauth2.Config, *oidc.Provider, *http.Client) provider.UpstreamOIDCIdentityProviderI
validateIDToken func(ctx context.Context, provider *oidc.Provider, audience string, token string) (*oidc.IDToken, error)
getProvider func(*oauth2.Config, *coreosoidc.Provider, *http.Client) provider.UpstreamOIDCIdentityProviderI
validateIDToken func(ctx context.Context, provider *coreosoidc.Provider, audience string, token string) (*coreosoidc.IDToken, error)
promptForValue func(ctx context.Context, promptLabel string) (string, error)
promptForSecret func(promptLabel string) (string, error)
@ -268,7 +268,7 @@ func Login(issuer string, clientID string, opts ...Option) (*oidctypes.Token, er
issuer: issuer,
clientID: clientID,
listenAddr: "localhost:0",
scopes: []string{oidc.ScopeOfflineAccess, oidc.ScopeOpenID, "email", "profile"},
scopes: []string{oidcapi.ScopeOfflineAccess, oidcapi.ScopeOpenID, oidcapi.ScopeEmail, oidcapi.ScopeProfile},
cache: &nopCache{},
callbackPath: "/callback",
ctx: context.Background(),
@ -285,8 +285,8 @@ func Login(issuer string, clientID string, opts ...Option) (*oidctypes.Token, er
listen: net.Listen,
isTTY: term.IsTerminal,
getProvider: upstreamoidc.New,
validateIDToken: func(ctx context.Context, provider *oidc.Provider, audience string, token string) (*oidc.IDToken, error) {
return provider.Verifier(&oidc.Config{ClientID: audience}).Verify(ctx, token)
validateIDToken: func(ctx context.Context, provider *coreosoidc.Provider, audience string, token string) (*coreosoidc.IDToken, error) {
return provider.Verifier(&coreosoidc.Config{ClientID: audience}).Verify(ctx, token)
},
promptForValue: promptForValue,
promptForSecret: promptForSecret,
@ -305,7 +305,7 @@ func Login(issuer string, clientID string, opts ...Option) (*oidctypes.Token, er
// Always set a long, but non-infinite timeout for this operation.
ctx, cancel := context.WithTimeout(h.ctx, overallTimeout)
defer cancel()
ctx = oidc.ClientContext(ctx, h.httpClient)
ctx = coreosoidc.ClientContext(ctx, h.httpClient)
h.ctx = ctx
// Initialize login parameters.
@ -386,10 +386,10 @@ func (h *handlerState) baseLogin() (*oidctypes.Token, error) {
}
if h.upstreamIdentityProviderName != "" {
authorizeOptions = append(authorizeOptions,
oauth2.SetAuthURLParam(supervisoroidc.AuthorizeUpstreamIDPNameParamName, h.upstreamIdentityProviderName),
oauth2.SetAuthURLParam(oidcapi.AuthorizeUpstreamIDPNameParamName, h.upstreamIdentityProviderName),
)
authorizeOptions = append(authorizeOptions,
oauth2.SetAuthURLParam(supervisoroidc.AuthorizeUpstreamIDPTypeParamName, h.upstreamIdentityProviderType),
oauth2.SetAuthURLParam(oidcapi.AuthorizeUpstreamIDPTypeParamName, h.upstreamIdentityProviderType),
)
}
@ -447,8 +447,8 @@ func (h *handlerState) cliBasedAuth(authorizeOptions *[]oauth2.AuthCodeOption) (
if err != nil {
return nil, fmt.Errorf("could not build authorize request: %w", err)
}
authReq.Header.Set(supervisoroidc.AuthorizeUsernameHeaderName, username)
authReq.Header.Set(supervisoroidc.AuthorizePasswordHeaderName, password)
authReq.Header.Set(oidcapi.AuthorizeUsernameHeaderName, username)
authReq.Header.Set(oidcapi.AuthorizePasswordHeaderName, password)
authRes, err := h.httpClient.Do(authReq)
if err != nil {
return nil, fmt.Errorf("authorization response error: %w", err)
@ -710,7 +710,7 @@ func (h *handlerState) initOIDCDiscovery() error {
h.logger.V(plog.KlogLevelDebug).Info("Pinniped: Performing OIDC discovery", "issuer", h.issuer)
var err error
h.provider, err = oidc.NewProvider(h.ctx, h.issuer)
h.provider, err = coreosoidc.NewProvider(h.ctx, h.issuer)
if err != nil {
return fmt.Errorf("could not perform OIDC discovery for %q: %w", h.issuer, err)
}
@ -775,7 +775,7 @@ func (h *handlerState) tokenExchangeRFC8693(baseToken *oidctypes.Token) (*oidcty
// Form the HTTP POST request with the parameters specified by RFC8693.
reqBody := strings.NewReader(url.Values{
"client_id": []string{h.clientID},
"grant_type": []string{"urn:ietf:params:oauth:grant-type:token-exchange"},
"grant_type": []string{oidcapi.GrantTypeTokenExchange},
"audience": []string{h.requestedAudience},
"subject_token": []string{baseToken.AccessToken.Token},
"subject_token_type": []string{"urn:ietf:params:oauth:token-type:access_token"},

View File

@ -1,7 +1,7 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package nonce implements
// Package nonce implements helpers for OIDC nonce parameter handling.
package nonce
import (
@ -11,7 +11,7 @@ import (
"fmt"
"io"
"github.com/coreos/go-oidc/v3/oidc"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
@ -36,11 +36,11 @@ func (n *Nonce) String() string {
// Param returns the OAuth2 auth code parameter for sending the nonce during the authorization request.
func (n *Nonce) Param() oauth2.AuthCodeOption {
return oidc.Nonce(string(*n))
return coreosoidc.Nonce(string(*n))
}
// Validate the returned ID token). Returns true iff the nonce matches or the returned JWT does not have a nonce.
func (n *Nonce) Validate(token *oidc.IDToken) error {
func (n *Nonce) Validate(token *coreosoidc.IDToken) error {
if subtle.ConstantTimeCompare([]byte(token.Nonce), []byte(*n)) != 1 {
return InvalidNonceError{Expected: *n, Got: Nonce(token.Nonce)}
}

View File

@ -188,7 +188,7 @@ The Supervisor's endpoints are:
- And a number of endpoints for each FederationDomain that is configured by the user.
Each FederationDomain's endpoints are mounted under the path of the FederationDomain's `spec.issuer`,
if the issuer as a path specified in its URL. If the issuer has no path, then they are mounted under `/`.
if the `spec.issuer` URL has a path component specified. If the issuer has no path, then they are mounted under `/`.
These per-FederationDomain endpoint are all mounted by the code in
[internal/oidc/provider/manager/manager.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/provider/manager/manager.go).
@ -202,6 +202,10 @@ The per-FederationDomain endpoints are:
See [internal/oidc/auth/auth_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/auth/auth_handler.go).
- `<issuer_path>/oauth2/token` is the standard OIDC token endpoint.
See [internal/oidc/token/token_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/token/token_handler.go).
The token endpoint can handle the standard OIDC `authorization_code` and `refresh_token` grant types, and has also been
extended in [internal/oidc/token_exchange.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/token_exchange.go)
to handle an additional grant type for [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchanges to
reduce the applicable scope (technically, the `aud` claim) of ID tokens.
- `<issuer_path>/callback` is a special endpoint that is used as the redirect URL when performing an OIDC authcode flow against an upstream OIDC identity provider as configured by an OIDCIdentityProvider custom resource.
See [internal/oidc/callback/callback_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/callback/callback_handler.go).
- `<issuer_path>/v1alpha1/pinniped_identity_providers` is a custom discovery endpoint for clients to learn about available upstream identity providers.

View File

@ -24,7 +24,6 @@ import (
"testing"
"time"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/creack/pty"
"github.com/sclevine/agouti"
"github.com/stretchr/testify/require"
@ -40,7 +39,6 @@ import (
"go.pinniped.dev/internal/certauthority"
"go.pinniped.dev/internal/crud"
"go.pinniped.dev/internal/here"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/testutil"
"go.pinniped.dev/pkg/oidcclient"
"go.pinniped.dev/pkg/oidcclient/filesession"
@ -53,6 +51,8 @@ import (
func TestE2EFullIntegration_Browser(t *testing.T) {
env := testlib.IntegrationEnv(t)
allScopes := []string{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}
// Avoid allowing PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW to interfere with these tests.
originalFlowEnvVarValue, flowOverrideEnvVarSet := os.LookupEnv("PINNIPED_UPSTREAM_IDENTITY_PROVIDER_FLOW")
if flowOverrideEnvVarSet {
@ -170,7 +170,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--oidc-skip-browser",
"--oidc-ca-bundle", testCABundlePath,
"--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
// use default for --oidc-scopes, which is to request all relevant scopes
})
// Run "kubectl get namespaces" which should trigger a browser login via the plugin.
@ -193,11 +193,12 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan))
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"})
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes)
})
// If scopes aren't specified, we don't request the groups scope, which means we won't get any groups back in our token.
t.Run("with Supervisor OIDC upstream IDP and browser flow, scopes not specified", func(t *testing.T) {
// If the username and groups scope are not requested by the CLI, then the CLI still gets them, to allow for
// backwards compatibility with old CLIs that did not request those scopes because they did not exist yet.
t.Run("with Supervisor OIDC upstream IDP and browser flow, downstream username and groups scopes not requested", func(t *testing.T) {
testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
t.Cleanup(cancel)
@ -249,6 +250,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--oidc-skip-browser",
"--oidc-ca-bundle", testCABundlePath,
"--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience", // does not request username or groups
})
// Run "kubectl get namespaces" which should trigger a browser login via the plugin.
@ -271,7 +273,12 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan))
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, []string{}, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience"})
// Note that the list of scopes param here is used to form the cache key for looking up local session storage.
// The scopes portion of the cache key is made up of the requested scopes from the CLI flag, not the granted
// scopes returned by the Supervisor, so list the requested scopes from the CLI flag here. This helper will
// assert that the expected username and groups claims/values are in the downstream ID token.
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath,
pinnipedExe, expectedUsername, []string{}, []string{"offline_access", "openid", "pinniped:request-audience"})
})
t.Run("with Supervisor OIDC upstream IDP and manual authcode copy-paste from browser flow", func(t *testing.T) {
@ -328,7 +335,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--oidc-skip-listen",
"--oidc-ca-bundle", testCABundlePath,
"--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
// use default for --oidc-scopes, which is to request all relevant scopes
})
// Run "kubectl get namespaces" which should trigger a browser login via the plugin.
@ -382,7 +389,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
t.Logf("first kubectl command took %s", time.Since(start).String())
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"})
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes)
})
t.Run("access token based refresh with Supervisor OIDC upstream IDP and manual authcode copy-paste from browser flow", func(t *testing.T) {
@ -447,7 +454,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--oidc-skip-listen",
"--oidc-ca-bundle", testCABundlePath,
"--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
// use default for --oidc-scopes, which is to request all relevant scopes
})
// Run "kubectl get namespaces" which should trigger a browser login via the plugin.
@ -518,7 +525,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
t.Logf("first kubectl command took %s", time.Since(start).String())
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"})
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes)
})
t.Run("with Supervisor OIDC upstream IDP and CLI password flow without web browser", func(t *testing.T) {
@ -574,7 +581,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--upstream-identity-provider-flow", "cli_password", // create a kubeconfig configured to use the cli_password flow
"--oidc-ca-bundle", testCABundlePath,
"--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
// use default for --oidc-scopes, which is to request all relevant scopes
})
// Run "kubectl get namespaces" which should trigger a browser-less CLI prompt login via the plugin.
@ -601,7 +608,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
t.Logf("first kubectl command took %s", time.Since(start).String())
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"})
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes)
})
t.Run("with Supervisor OIDC upstream IDP and CLI password flow when OIDCIdentityProvider disallows it", func(t *testing.T) {
@ -648,7 +655,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--upstream-identity-provider-flow", "cli_password",
"--oidc-ca-bundle", testCABundlePath,
"--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
// use default for --oidc-scopes, which is to request all relevant scopes
})
// Run "kubectl get --raw /healthz" which should trigger a browser-less CLI prompt login via the plugin.
@ -710,7 +717,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--concierge-authenticator-type", "jwt",
"--concierge-authenticator-name", authenticator.Name,
"--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
// use default for --oidc-scopes, which is to request all relevant scopes
})
// Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt via the plugin.
@ -737,7 +744,66 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
t.Logf("first kubectl command took %s", time.Since(start).String())
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"})
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes)
})
// If the username and groups scope are not requested by the CLI, then the CLI still gets them, to allow for
// backwards compatibility with old CLIs that did not request those scopes because they did not exist yet.
t.Run("with Supervisor LDAP upstream IDP using username and password prompts, downstream username and groups scopes not requested", func(t *testing.T) {
testlib.SkipTestWhenLDAPIsUnavailable(t, env)
testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
t.Cleanup(cancel)
tempDir := testutil.TempDir(t) // per-test tmp dir to avoid sharing files between tests
expectedUsername := env.SupervisorUpstreamLDAP.TestUserMailAttributeValue
expectedGroups := env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs
setupClusterForEndToEndLDAPTest(t, expectedUsername, env)
// Use a specific session cache for this test.
sessionCachePath := tempDir + "/test-sessions.yaml"
kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{
"get", "kubeconfig",
"--concierge-api-group-suffix", env.APIGroupSuffix,
"--concierge-authenticator-type", "jwt",
"--concierge-authenticator-name", authenticator.Name,
"--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience", // does not request username or groups
})
// Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt via the plugin.
start := time.Now()
kubectlCmd := exec.CommandContext(testCtx, "kubectl", "get", "namespace", "--kubeconfig", kubeconfigPath)
kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...)
ptyFile, err := pty.Start(kubectlCmd)
require.NoError(t, err)
// Wait for the subprocess to print the username prompt, then type the user's username.
readFromFileUntilStringIsSeen(t, ptyFile, "Username: ")
_, err = ptyFile.WriteString(expectedUsername + "\n")
require.NoError(t, err)
// Wait for the subprocess to print the password prompt, then type the user's password.
readFromFileUntilStringIsSeen(t, ptyFile, "Password: ")
_, err = ptyFile.WriteString(env.SupervisorUpstreamLDAP.TestUserPassword + "\n")
require.NoError(t, err)
// Read all output from the subprocess until EOF.
// Ignore any errors returned because there is always an error on linux.
kubectlOutputBytes, _ := ioutil.ReadAll(ptyFile)
requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes))
t.Logf("first kubectl command took %s", time.Since(start).String())
// Note that the list of scopes param here is used to form the cache key for looking up local session storage.
// The scopes portion of the cache key is made up of the requested scopes from the CLI flag, not the granted
// scopes returned by the Supervisor, so list the requested scopes from the CLI flag here. This helper will
// assert that the expected username and groups claims/values are in the downstream ID token.
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath,
pinnipedExe, expectedUsername, expectedGroups, []string{"offline_access", "openid", "pinniped:request-audience"})
})
// Add an LDAP upstream IDP and try using it to authenticate during kubectl commands
@ -764,7 +830,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--concierge-authenticator-type", "jwt",
"--concierge-authenticator-name", authenticator.Name,
"--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
// use default for --oidc-scopes, which is to request all relevant scopes
})
// Set up the username and password env vars to avoid the interactive prompts.
@ -803,7 +869,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
require.NoError(t, os.Unsetenv(usernameEnvVar))
require.NoError(t, os.Unsetenv(passwordEnvVar))
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"})
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes)
})
// Add an Active Directory upstream IDP and try using it to authenticate during kubectl commands
@ -830,7 +896,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--concierge-authenticator-type", "jwt",
"--concierge-authenticator-name", authenticator.Name,
"--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
// use default for --oidc-scopes, which is to request all relevant scopes
})
// Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt via the plugin.
@ -857,7 +923,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
t.Logf("first kubectl command took %s", time.Since(start).String())
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"})
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes)
})
// Add an ActiveDirectory upstream IDP and try using it to authenticate during kubectl commands
@ -884,7 +950,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--concierge-authenticator-type", "jwt",
"--concierge-authenticator-name", authenticator.Name,
"--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
// use default for --oidc-scopes, which is to request all relevant scopes
})
// Set up the username and password env vars to avoid the interactive prompts.
@ -923,7 +989,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
require.NoError(t, os.Unsetenv(usernameEnvVar))
require.NoError(t, os.Unsetenv(passwordEnvVar))
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"})
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes)
})
// Add an LDAP upstream IDP and try using it to authenticate during kubectl commands, using the browser flow.
@ -955,7 +1021,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--oidc-ca-bundle", testCABundlePath,
"--upstream-identity-provider-flow", "browser_authcode",
"--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
// use default for --oidc-scopes, which is to request all relevant scopes
})
// Run "kubectl get namespaces" which should trigger a browser login via the plugin.
@ -973,7 +1039,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan))
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"})
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes)
})
// Add an Active Directory upstream IDP and try using it to authenticate during kubectl commands, using the browser flow.
@ -1005,7 +1071,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--oidc-ca-bundle", testCABundlePath,
"--upstream-identity-provider-flow", "browser_authcode",
"--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
// use default for --oidc-scopes, which is to request all relevant scopes
})
// Run "kubectl get namespaces" which should trigger a browser login via the plugin.
@ -1023,7 +1089,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan))
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"})
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes)
})
// Add an LDAP upstream IDP and try using it to authenticate during kubectl commands, using the env var to choose the browser flow.
@ -1055,7 +1121,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
"--oidc-ca-bundle", testCABundlePath,
"--upstream-identity-provider-flow", "cli_password", // put cli_password in the kubeconfig, so we can override it with the env var
"--oidc-session-cache", sessionCachePath,
"--oidc-scopes", "offline_access,openid,pinniped:request-audience,groups",
// use default for --oidc-scopes, which is to request all relevant scopes
})
// Override the --upstream-identity-provider-flow flag from the kubeconfig using the env var.
@ -1079,7 +1145,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan))
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"})
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes)
})
}
@ -1337,17 +1403,17 @@ func requireUserCanUseKubectlWithoutAuthenticatingAgain(
requireGCAnnotationsOnSessionStorage(ctx, t, env.SupervisorNamespace, startTime, token)
idTokenClaims := token.IDToken.Claims
require.Equal(t, expectedUsername, idTokenClaims[oidc.DownstreamUsernameClaim])
require.Equal(t, expectedUsername, idTokenClaims["username"])
if expectedGroups == nil {
require.Nil(t, idTokenClaims[oidc.DownstreamGroupsClaim])
require.Nil(t, idTokenClaims["groups"])
} else {
// The groups claim in the file ends up as an []interface{}, so adjust our expectation to match.
expectedGroupsAsEmptyInterfaces := make([]interface{}, 0, len(expectedGroups))
for _, g := range expectedGroups {
expectedGroupsAsEmptyInterfaces = append(expectedGroupsAsEmptyInterfaces, g)
}
require.ElementsMatch(t, expectedGroupsAsEmptyInterfaces, idTokenClaims[oidc.DownstreamGroupsClaim])
require.ElementsMatch(t, expectedGroupsAsEmptyInterfaces, idTokenClaims["groups"])
}
expectedGroupsPlusAuthenticated := append([]string{}, expectedGroups...)

View File

@ -502,11 +502,11 @@ func requireWellKnownEndpointIsWorking(t *testing.T, supervisorScheme, superviso
"token_endpoint": "%s/oauth2/token",
"token_endpoint_auth_methods_supported": ["client_secret_basic"],
"jwks_uri": "%s/jwks.json",
"scopes_supported": ["openid", "offline"],
"scopes_supported": ["openid", "offline_access", "pinniped:request-audience", "username", "groups"],
"response_types_supported": ["code"],
"response_modes_supported": ["query", "form_post"],
"code_challenge_methods_supported": ["S256"],
"claims_supported": ["groups"],
"claims_supported": ["username", "groups"],
"discovery.supervisor.pinniped.dev/v1alpha1": {"pinniped_identity_providers_endpoint": "%s/v1alpha1/pinniped_identity_providers"},
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["ES256"]

View File

@ -207,6 +207,9 @@ func TestSupervisorLogin_Browser(t *testing.T) {
// The scopes to request from the authorization endpoint. Defaults will be used when not specified.
downstreamScopes []string
// The scopes to want granted from the authorization endpoint. Defaults to the downstreamScopes value when not,
// specified, i.e. by default it expects that all requested scopes were granted.
wantDownstreamScopes []string
// When we want the localhost callback to have never happened, then the flow will stop there. The login was
// unable to finish so there is nothing to assert about what should have happened with the callback, and there
@ -218,6 +221,7 @@ func TestSupervisorLogin_Browser(t *testing.T) {
// The expected ID token subject claim value as a regexp, for the original ID token and the refreshed ID token.
wantDownstreamIDTokenSubjectToMatch string
// The expected ID token username claim value as a regexp, for the original ID token and the refreshed ID token.
// This function should return an empty string when there should be no username claim in the ID tokens.
wantDownstreamIDTokenUsernameToMatch func(username string) string
// The expected ID token groups claim value, for the original ID token and the refreshed ID token.
wantDownstreamIDTokenGroups []string
@ -240,7 +244,8 @@ func TestSupervisorLogin_Browser(t *testing.T) {
wantTokenExchangeResponse func(t *testing.T, status int, body string)
// Optionally edit the refresh session data between the initial login and the first refresh,
// which is still expected to succeed after these edits.
// which is still expected to succeed after these edits. Returns the group memberships expected after the
// refresh is performed.
editRefreshSessionDataWithoutBreaking func(t *testing.T, sessionData *psession.PinnipedSession, idpName, username string) []string
// Optionally either revoke the user's session on the upstream provider, or manipulate the user's session
// data in such a way that it should cause the next upstream refresh attempt to fail.
@ -278,8 +283,8 @@ func TestSupervisorLogin_Browser(t *testing.T) {
},
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC,
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
fositeSessionData := pinnipedSession.Fosite
fositeSessionData.Claims.Extra["username"] = "some-incorrect-username"
customSessionData := pinnipedSession.Custom
customSessionData.Username = "some-incorrect-username"
},
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" },
@ -321,8 +326,8 @@ func TestSupervisorLogin_Browser(t *testing.T) {
},
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC,
breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) {
fositeSessionData := pinnipedSession.Fosite
fositeSessionData.Claims.Extra["username"] = "some-incorrect-username"
customSessionData := pinnipedSession.Custom
customSessionData.Username = "some-incorrect-username"
},
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" },
@ -400,13 +405,14 @@ func TestSupervisorLogin_Browser(t *testing.T) {
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
},
{
name: "ldap without requesting groups scope",
name: "ldap without requesting username and groups scope gets them anyway for pinniped-cli for backwards compatibility with old CLIs",
maybeSkip: skipLDAPTests,
createIDP: func(t *testing.T) string {
idp, _ := createLDAPIdentityProvider(t, nil)
return idp.Name
},
downstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access"},
downstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access"},
wantDownstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access", "username", "groups"},
requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) {
requestAuthorizationUsingCLIPasswordFlow(t,
downstreamAuthorizeURL,
@ -426,10 +432,10 @@ func TestSupervisorLogin_Browser(t *testing.T) {
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
},
wantDownstreamIDTokenGroups: []string{},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
},
{
name: "oidc without requesting groups scope",
name: "oidc without requesting username and groups scope gets them anyway for pinniped-cli for backwards compatibility with old CLIs",
maybeSkip: skipNever,
createIDP: func(t *testing.T) string {
spec := basicOIDCIdentityProviderSpec()
@ -443,10 +449,11 @@ func TestSupervisorLogin_Browser(t *testing.T) {
return testlib.CreateTestOIDCIdentityProvider(t, spec, idpv1alpha1.PhaseReady).Name
},
downstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access"},
wantDownstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access", "username", "groups"},
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC,
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" },
wantDownstreamIDTokenGroups: nil,
wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups,
},
{
name: "ldap with browser flow",
@ -649,8 +656,7 @@ func TestSupervisorLogin_Browser(t *testing.T) {
customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.LDAP.UserDN)
fositeSessionData := pinnipedSession.Fosite
fositeSessionData.Claims.Extra["username"] = "not-the-same"
customSessionData.Username = "not-the-same"
},
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
@ -829,8 +835,7 @@ func TestSupervisorLogin_Browser(t *testing.T) {
customSessionData := pinnipedSession.Custom
require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType)
require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN)
fositeSessionData := pinnipedSession.Fosite
fositeSessionData.Claims.Extra["username"] = "not-the-same"
customSessionData.Username = "not-the-same"
},
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
@ -1284,7 +1289,7 @@ func TestSupervisorLogin_Browser(t *testing.T) {
},
},
{
name: "oidc upstream with downstream dynamic client happy path",
name: "oidc upstream with downstream dynamic client happy path, requesting all scopes",
maybeSkip: skipNever,
createIDP: func(t *testing.T) string {
return testlib.CreateTestOIDCIdentityProvider(t, basicOIDCIdentityProviderSpec(), idpv1alpha1.PhaseReady).Name
@ -1301,9 +1306,10 @@ func TestSupervisorLogin_Browser(t *testing.T) {
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+",
// the ID token Username should include the upstream user ID after the upstream issuer name
wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" },
wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups,
},
{
name: "ldap upstream with downstream dynamic client happy path",
name: "ldap upstream with downstream dynamic client happy path, requesting all scopes",
maybeSkip: skipLDAPTests,
createIDP: func(t *testing.T) string {
idp, _ := createLDAPIdentityProvider(t, nil)
@ -1334,6 +1340,237 @@ func TestSupervisorLogin_Browser(t *testing.T) {
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
},
{
name: "ldap upstream with downstream dynamic client when dynamic client is not allowed to use the token exchange grant type, causes token exchange error",
maybeSkip: skipLDAPTests,
createIDP: func(t *testing.T) string {
idp, _ := createLDAPIdentityProvider(t, nil)
return idp.Name
},
createOIDCClient: func(t *testing.T, callbackURL string) (string, string) {
return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{
AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)},
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange grant type not allowed
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "username", "groups"}, // a validation requires that we also disallow the pinniped:request-audience scope
}, configv1alpha1.PhaseReady)
},
testUser: func(t *testing.T) (string, string) {
// return the username and password of the existing user that we want to use for this test
return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login
},
downstreamScopes: []string{"openid", "offline_access", "username", "groups"}, // does not request (or expect) pinniped:request-audience token exchange scope
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP,
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
wantTokenExchangeResponse: func(t *testing.T, status int, body string) { // can't do token exchanges without the token exchange grant type
require.Equal(t, http.StatusBadRequest, status)
require.Equal(t,
`{"error":"unauthorized_client","error_description":"The client is not authorized to request a token using this method. `+
`The OAuth 2.0 Client is not allowed to use token exchange grant 'urn:ietf:params:oauth:grant-type:token-exchange'."}`,
body)
},
},
{
name: "ldap upstream with downstream dynamic client when dynamic client that does not request the pinniped:request-audience scope, causes token exchange error",
maybeSkip: skipLDAPTests,
createIDP: func(t *testing.T) string {
idp, _ := createLDAPIdentityProvider(t, nil)
return idp.Name
},
createOIDCClient: func(t *testing.T, callbackURL string) (string, string) {
return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{
AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)},
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"},
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"},
}, configv1alpha1.PhaseReady)
},
testUser: func(t *testing.T) (string, string) {
// return the username and password of the existing user that we want to use for this test
return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login
},
downstreamScopes: []string{"openid", "offline_access", "username", "groups"}, // does not request (or expect) pinniped:request-audience token exchange scope
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP,
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
wantTokenExchangeResponse: func(t *testing.T, status int, body string) { // can't do token exchanges without the pinniped:request-audience token exchange scope
require.Equal(t, http.StatusForbidden, status)
require.Equal(t,
`{"error":"access_denied","error_description":"The resource owner or authorization server denied the request. `+
`missing the 'pinniped:request-audience' scope"}`,
body)
},
},
{
name: "ldap upstream with downstream dynamic client when dynamic client is not allowed to request username but requests username anyway, causes authorization error",
maybeSkip: skipLDAPTests,
createIDP: func(t *testing.T) string {
idp, _ := createLDAPIdentityProvider(t, nil)
return idp.Name
},
createOIDCClient: func(t *testing.T, callbackURL string) (string, string) {
return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{
AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)},
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude groups scope)
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "groups"}, // username not allowed
}, configv1alpha1.PhaseReady)
},
testUser: func(t *testing.T) (string, string) {
// return the username and password of the existing user that we want to use for this test
return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login
},
downstreamScopes: []string{"openid", "offline_access", "username"}, // request username, even though the client is not allowed to request it
// Should have been immediately redirected back to the local callback server with an error in this case,
// since we requested a scope that the client is not allowed to request. The login UI page is never shown.
requestAuthorization: requestAuthorizationAndExpectImmediateRedirectToCallback,
wantAuthorizationErrorDescription: "The requested scope is invalid, unknown, or malformed. The OAuth 2.0 Client is not allowed to request scope 'username'.",
wantAuthorizationErrorType: "invalid_scope",
},
{
name: "ldap upstream with downstream dynamic client when dynamic client is not allowed to request groups but requests groups anyway, causes authorization error",
maybeSkip: skipLDAPTests,
createIDP: func(t *testing.T) string {
idp, _ := createLDAPIdentityProvider(t, nil)
return idp.Name
},
createOIDCClient: func(t *testing.T, callbackURL string) (string, string) {
return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{
AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)},
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude groups scope)
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "username"}, // groups not allowed
}, configv1alpha1.PhaseReady)
},
testUser: func(t *testing.T) (string, string) {
// return the username and password of the existing user that we want to use for this test
return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login
},
downstreamScopes: []string{"openid", "offline_access", "groups"}, // request groups, even though the client is not allowed to request it
// Should have been immediately redirected back to the local callback server with an error in this case,
// since we requested a scope that the client is not allowed to request. The login UI page is never shown.
requestAuthorization: requestAuthorizationAndExpectImmediateRedirectToCallback,
wantAuthorizationErrorDescription: "The requested scope is invalid, unknown, or malformed. The OAuth 2.0 Client is not allowed to request scope 'groups'.",
wantAuthorizationErrorType: "invalid_scope",
},
{
name: "ldap upstream with downstream dynamic client when dynamic client does not request groups happy path",
maybeSkip: skipLDAPTests,
createIDP: func(t *testing.T) string {
idp, _ := createLDAPIdentityProvider(t, nil)
return idp.Name
},
createOIDCClient: func(t *testing.T, callbackURL string) (string, string) {
return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{
AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)},
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"},
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"},
}, configv1alpha1.PhaseReady)
},
testUser: func(t *testing.T) (string, string) {
// return the username and password of the existing user that we want to use for this test
return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login
},
downstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access", "username"}, // do not request (or expect) groups
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP,
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
"ldaps://"+env.SupervisorUpstreamLDAP.Host+
"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
) + "$",
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$"
},
wantDownstreamIDTokenGroups: nil, // did not request groups, so should not have got any groups
},
{
name: "ldap upstream with downstream dynamic client when dynamic client does not request username, is allowed to auth but cannot do token exchange",
maybeSkip: skipLDAPTests,
createIDP: func(t *testing.T) string {
idp, _ := createLDAPIdentityProvider(t, nil)
return idp.Name
},
createOIDCClient: func(t *testing.T, callbackURL string) (string, string) {
return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{
AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)},
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"},
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"},
}, configv1alpha1.PhaseReady)
},
testUser: func(t *testing.T) (string, string) {
// return the username and password of the existing user that we want to use for this test
return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login
},
downstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access", "groups"}, // do not request (or expect) username
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP,
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
"ldaps://"+env.SupervisorUpstreamLDAP.Host+
"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
) + "$",
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
return "" // username should not exist as a claim since we did not request it
},
wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs,
wantTokenExchangeResponse: func(t *testing.T, status int, body string) { // can't do token exchanges without a username
require.Equal(t, http.StatusForbidden, status)
require.Equal(t,
`{"error":"access_denied","error_description":"The resource owner or authorization server denied the request. `+
`No username found in session. Ensure that the 'username' scope was requested and granted at the authorization endpoint."}`,
body)
},
},
{
name: "ldap upstream with downstream dynamic client when dynamic client is not allowed to request username or groups and does not request them, is allowed to auth but cannot do token exchange",
maybeSkip: skipLDAPTests,
createIDP: func(t *testing.T) string {
idp, _ := createLDAPIdentityProvider(t, nil)
return idp.Name
},
createOIDCClient: func(t *testing.T, callbackURL string) (string, string) {
return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{
AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)},
AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"},
AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access"}, // validations require that when username/groups are excluded, then token exchange must also not be allowed
}, configv1alpha1.PhaseReady)
},
testUser: func(t *testing.T) (string, string) {
// return the username and password of the existing user that we want to use for this test
return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login
env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login
},
downstreamScopes: []string{"openid", "offline_access"}, // do not request (or expect) pinniped:request-audience or username or groups
requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP,
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(
"ldaps://"+env.SupervisorUpstreamLDAP.Host+
"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+
"&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
) + "$",
wantDownstreamIDTokenUsernameToMatch: func(_ string) string {
return "" // username should not exist as a claim since we did not request it
},
wantDownstreamIDTokenGroups: nil, // did not request groups, so should not have got any groups
wantTokenExchangeResponse: func(t *testing.T, status int, body string) { // can't do token exchanges without the token exchange grant type
require.Equal(t, http.StatusBadRequest, status)
require.Equal(t,
`{"error":"unauthorized_client","error_description":"The client is not authorized to request a token using this method. `+
`The OAuth 2.0 Client is not allowed to use token exchange grant 'urn:ietf:params:oauth:grant-type:token-exchange'."}`,
body)
},
},
{
name: "active directory with all default options with downstream dynamic client happy path",
maybeSkip: skipActiveDirectoryTests,
@ -1411,6 +1648,7 @@ func TestSupervisorLogin_Browser(t *testing.T) {
tt.createOIDCClient,
tt.downstreamScopes,
tt.requestTokenExchangeAud,
tt.wantDownstreamScopes,
tt.wantLocalhostCallbackToNeverHappen,
tt.wantDownstreamIDTokenSubjectToMatch,
tt.wantDownstreamIDTokenUsernameToMatch,
@ -1552,6 +1790,7 @@ func testSupervisorLogin(
createOIDCClient func(t *testing.T, callbackURL string) (string, string),
downstreamScopes []string,
requestTokenExchangeAud string,
wantDownstreamScopes []string,
wantLocalhostCallbackToNeverHappen bool,
wantDownstreamIDTokenSubjectToMatch string,
wantDownstreamIDTokenUsernameToMatch func(username string) string,
@ -1672,7 +1911,13 @@ func testSupervisorLogin(
}, 30*time.Second, 200*time.Millisecond)
if downstreamScopes == nil {
downstreamScopes = []string{"openid", "pinniped:request-audience", "offline_access", "groups"}
// By default, tests will request all the relevant groups.
downstreamScopes = []string{"openid", "pinniped:request-audience", "offline_access", "username", "groups"}
}
if wantDownstreamScopes == nil {
// By default, tests will want that all requested scopes were granted.
wantDownstreamScopes = make([]string, len(downstreamScopes))
copy(wantDownstreamScopes, downstreamScopes)
}
// Create the OAuth2 configuration.
@ -1728,14 +1973,14 @@ func testSupervisorLogin(
if wantAuthorizationErrorType != "" {
errorDescription := callback.URL.Query().Get("error_description")
errorType := callback.URL.Query().Get("error")
require.Equal(t, errorDescription, wantAuthorizationErrorDescription)
require.Equal(t, errorType, wantAuthorizationErrorType)
require.Equal(t, wantAuthorizationErrorDescription, errorDescription)
require.Equal(t, wantAuthorizationErrorType, errorType)
// The authorization has failed, so can't continue the login flow, making this the end of the test case.
return
}
require.Equal(t, stateParam.String(), callback.URL.Query().Get("state"))
require.ElementsMatch(t, downstreamScopes, strings.Split(callback.URL.Query().Get("scope"), " "))
require.ElementsMatch(t, wantDownstreamScopes, strings.Split(callback.URL.Query().Get("scope"), " "))
authcode := callback.URL.Query().Get("code")
require.NotEmpty(t, authcode)
@ -1750,8 +1995,14 @@ func testSupervisorLogin(
return
}
require.NoError(t, err)
expectedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat", "username"}
if slices.Contains(downstreamScopes, "groups") {
expectedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat", "azp"}
if slices.Contains(wantDownstreamScopes, "username") {
// If the test wants the username scope to have been granted, then also expect the claim in the ID token.
expectedIDTokenClaims = append(expectedIDTokenClaims, "username")
}
if slices.Contains(wantDownstreamScopes, "groups") {
// If the test wants the groups scope to have been granted, then also expect the claim in the ID token.
expectedIDTokenClaims = append(expectedIDTokenClaims, "groups")
}
verifyTokenResponse(t,
@ -1764,7 +2015,7 @@ func testSupervisorLogin(
}
doTokenExchange(t, requestTokenExchangeAud, &downstreamOAuth2Config, tokenResponse, httpClient, discovery, wantTokenExchangeResponse)
refreshedGroups := wantDownstreamIDTokenGroups
wantRefreshedGroups := wantDownstreamIDTokenGroups
if editRefreshSessionDataWithoutBreaking != nil {
latestRefreshToken := tokenResponse.RefreshToken
signatureOfLatestRefreshToken := getFositeDataSignature(t, latestRefreshToken)
@ -1780,7 +2031,7 @@ func testSupervisorLogin(
pinnipedSession, ok := storedRefreshSession.GetSession().(*psession.PinnipedSession)
require.True(t, ok, "should have been able to cast session data to PinnipedSession")
refreshedGroups = editRefreshSessionDataWithoutBreaking(t, pinnipedSession, idpName, username)
wantRefreshedGroups = editRefreshSessionDataWithoutBreaking(t, pinnipedSession, idpName, username)
// Then save the mutated Secret back to Kubernetes.
// There is no update function, so delete and create again at the same name.
@ -1793,13 +2044,18 @@ func testSupervisorLogin(
require.NoError(t, err)
// When refreshing, expect to get an "at_hash" claim, but no "nonce" claim.
expectRefreshedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "rat", "username", "at_hash"}
if slices.Contains(downstreamScopes, "groups") {
expectRefreshedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "rat", "azp", "at_hash"}
if slices.Contains(wantDownstreamScopes, "username") {
// If the test wants the username scope to have been granted, then also expect the claim in the refreshed ID token.
expectRefreshedIDTokenClaims = append(expectRefreshedIDTokenClaims, "username")
}
if slices.Contains(wantDownstreamScopes, "groups") {
// If the test wants the groups scope to have been granted, then also expect the claim in the refreshed ID token.
expectRefreshedIDTokenClaims = append(expectRefreshedIDTokenClaims, "groups")
}
verifyTokenResponse(t,
refreshedTokenResponse, discovery, downstreamOAuth2Config, "",
expectRefreshedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), refreshedGroups)
expectRefreshedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), wantRefreshedGroups)
require.NotEqual(t, tokenResponse.AccessToken, refreshedTokenResponse.AccessToken)
require.NotEqual(t, tokenResponse.RefreshToken, refreshedTokenResponse.RefreshToken)
@ -1892,8 +2148,15 @@ func verifyTokenResponse(
}
require.ElementsMatch(t, expectedIDTokenClaims, idTokenClaimNames)
// Check username claim of the ID token.
require.Regexp(t, wantDownstreamIDTokenUsernameToMatch, idTokenClaims["username"].(string))
// There should always be an "azp" claim, and the value should be the client ID of the client which made
// the authorization request.
require.Equal(t, downstreamOAuth2Config.ClientID, idTokenClaims["azp"])
// Check username claim of the ID token, if one is expected. Asserting on the lack of a username claim is
// handled above where the full list of claims are asserted.
if wantDownstreamIDTokenUsernameToMatch != "" {
require.Regexp(t, wantDownstreamIDTokenUsernameToMatch, idTokenClaims["username"].(string))
}
// Check the groups claim.
require.ElementsMatch(t, wantDownstreamIDTokenGroups, idTokenClaims["groups"])
@ -1912,6 +2175,21 @@ func verifyTokenResponse(
require.True(t, strings.HasPrefix(tokenResponse.RefreshToken, "pin_rt_"), "token %q did not have expected prefix 'pin_rt_'", tokenResponse.RefreshToken)
}
func requestAuthorizationAndExpectImmediateRedirectToCallback(t *testing.T, _, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, _ *http.Client) {
t.Helper()
// Open the web browser and navigate to the downstream authorize URL.
page := browsertest.Open(t)
t.Logf("opening browser to downstream authorize URL %s", testlib.MaskTokens(downstreamAuthorizeURL))
require.NoError(t, page.Navigate(downstreamAuthorizeURL))
// Expect that it immediately redirects back to the callback, which is what happens for certain types of errors
// where it is not worth redirecting to the login UI page.
t.Logf("waiting for redirect to callback")
callbackURLPattern := regexp.MustCompile(`\A` + regexp.QuoteMeta(downstreamCallbackURL) + `\?.+\z`)
browsertest.WaitForURL(t, page, callbackURLPattern)
}
func requestAuthorizationUsingBrowserAuthcodeFlowOIDC(t *testing.T, _, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, httpClient *http.Client) {
t.Helper()
env := testlib.IntegrationEnv(t)
@ -2149,6 +2427,11 @@ func doTokenExchange(
indentedClaims, err := json.MarshalIndent(claims, " ", " ")
require.NoError(t, err)
t.Logf("exchanged token claims:\n%s", string(indentedClaims))
// The original client ID should be preserved in the azp claim, therefore preserving this information
// about the original source of the authorization for tracing/auditing purposes, since the "aud" claim
// has been updated to have a new value.
require.Equal(t, config.ClientID, claims["azp"])
}
func expectSecurityHeaders(t *testing.T, response *http.Response, expectFositeToOverrideSome bool) {

View File

@ -19,7 +19,6 @@ import (
"testing"
"time"
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/creack/pty"
"github.com/stretchr/testify/require"
authorizationv1 "k8s.io/api/authorization/v1"
@ -173,7 +172,7 @@ func TestSupervisorWarnings_Browser(t *testing.T) {
}))
// construct the cache key
downstreamScopes := []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"}
downstreamScopes := []string{"offline_access", "openid", "pinniped:request-audience", "groups"}
sort.Strings(downstreamScopes)
sessionCacheKey := oidcclient.SessionCacheKey{
Issuer: downstream.Spec.Issuer,
@ -481,7 +480,7 @@ func TestSupervisorWarnings_Browser(t *testing.T) {
}))
// construct the cache key
downstreamScopes := []string{coreosoidc.ScopeOfflineAccess, coreosoidc.ScopeOpenID, "pinniped:request-audience", "groups"}
downstreamScopes := []string{"offline_access", "openid", "pinniped:request-audience", "groups"}
sort.Strings(downstreamScopes)
sessionCacheKey := oidcclient.SessionCacheKey{
Issuer: downstream.Spec.Issuer,