Move Supervisor IDP discovery to its own new endpoint

This commit is contained in:
Ryan Richard 2021-05-11 10:31:33 -07:00
parent 778c194cc4
commit e25eb05450
9 changed files with 660 additions and 231 deletions

View File

@ -95,8 +95,12 @@ type getKubeconfigParams struct {
credentialCachePathSet bool credentialCachePathSet bool
} }
type supervisorDiscoveryResponse struct { type supervisorOIDCDiscoveryResponse struct {
PinnipedIDPs []pinnipedIDPResponse `json:"pinniped_idps"` PinnipedIDPsEndpoint string `json:"pinniped_identity_providers_endpoint"`
}
type supervisorIDPsDiscoveryResponse struct {
PinnipedIDPs []pinnipedIDPResponse `json:"pinniped_identity_providers"`
} }
type pinnipedIDPResponse struct { type pinnipedIDPResponse struct {
@ -727,57 +731,38 @@ func hasPendingStrategy(credentialIssuer *configv1alpha1.CredentialIssuer) bool
} }
func discoverSupervisorUpstreamIDP(ctx context.Context, flags *getKubeconfigParams) error { func discoverSupervisorUpstreamIDP(ctx context.Context, flags *getKubeconfigParams) error {
issuerDiscoveryURL := flags.oidc.issuer + "/.well-known/openid-configuration"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, issuerDiscoveryURL, nil)
if err != nil {
return fmt.Errorf("while forming request to issuer URL: %w", err)
}
transport := &http.Transport{ transport := &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
Proxy: http.ProxyFromEnvironment, Proxy: http.ProxyFromEnvironment,
} }
httpClient := http.Client{Transport: transport} httpClient := &http.Client{Transport: transport}
if flags.oidc.caBundle != nil { if flags.oidc.caBundle != nil {
rootCAs := x509.NewCertPool() rootCAs := x509.NewCertPool()
ok := rootCAs.AppendCertsFromPEM(flags.oidc.caBundle) ok := rootCAs.AppendCertsFromPEM(flags.oidc.caBundle)
if !ok { if !ok {
return fmt.Errorf("unable to fetch discovery data from issuer: could not parse CA bundle") return fmt.Errorf("unable to fetch OIDC discovery data from issuer: could not parse CA bundle")
} }
transport.TLSClientConfig.RootCAs = rootCAs transport.TLSClientConfig.RootCAs = rootCAs
} }
response, err := httpClient.Do(request) pinnipedIDPsEndpoint, err := discoverIDPsDiscoveryEndpointURL(ctx, flags.oidc.issuer, httpClient)
if err != nil { if err != nil {
return fmt.Errorf("unable to fetch discovery data from issuer: %w", err) return err
} }
defer func() { if pinnipedIDPsEndpoint == "" {
_ = response.Body.Close() // The issuer is not advertising itself as a Pinniped Supervisor which supports upstream IDP discovery.
}()
if response.StatusCode == http.StatusNotFound {
// 404 Not Found is not an error because OIDC discovery is an optional part of the OIDC spec.
return nil return nil
} }
if response.StatusCode != http.StatusOK {
// Other types of error responses aside from 404 are not expected.
return fmt.Errorf("unable to fetch discovery data from issuer: unexpected http response status: %s", response.Status)
}
rawBody, err := ioutil.ReadAll(response.Body) upstreamIDPs, err := discoverAllAvailableSupervisorUpstreamIDPs(ctx, pinnipedIDPsEndpoint, httpClient)
if err != nil { if err != nil {
return fmt.Errorf("unable to fetch discovery data from issuer: could not read response body: %w", err) return err
} }
var body supervisorDiscoveryResponse if len(upstreamIDPs) == 1 {
err = json.Unmarshal(rawBody, &body) flags.oidc.upstreamIDPName = upstreamIDPs[0].Name
if err != nil { flags.oidc.upstreamIDPType = upstreamIDPs[0].Type
return fmt.Errorf("unable to fetch discovery data from issuer: could not parse response JSON: %w", err) } else if len(upstreamIDPs) > 1 {
} idpName, idpType, err := selectUpstreamIDP(upstreamIDPs, flags.oidc.upstreamIDPName, flags.oidc.upstreamIDPType)
if len(body.PinnipedIDPs) == 1 {
flags.oidc.upstreamIDPName = body.PinnipedIDPs[0].Name
flags.oidc.upstreamIDPType = body.PinnipedIDPs[0].Type
} else if len(body.PinnipedIDPs) > 1 {
idpName, idpType, err := selectUpstreamIDP(body.PinnipedIDPs, flags.oidc.upstreamIDPName, flags.oidc.upstreamIDPType)
if err != nil { if err != nil {
return err return err
} }
@ -787,6 +772,74 @@ func discoverSupervisorUpstreamIDP(ctx context.Context, flags *getKubeconfigPara
return nil return nil
} }
func discoverIDPsDiscoveryEndpointURL(ctx context.Context, issuer string, httpClient *http.Client) (string, error) {
issuerDiscoveryURL := issuer + "/.well-known/openid-configuration"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, issuerDiscoveryURL, nil)
if err != nil {
return "", fmt.Errorf("while forming request to issuer URL: %w", err)
}
response, err := httpClient.Do(request)
if err != nil {
return "", fmt.Errorf("unable to fetch OIDC discovery data from issuer: %w", err)
}
defer func() {
_ = response.Body.Close()
}()
if response.StatusCode == http.StatusNotFound {
// 404 Not Found is not an error because OIDC discovery is an optional part of the OIDC spec.
return "", nil
}
if response.StatusCode != http.StatusOK {
// Other types of error responses aside from 404 are not expected.
return "", fmt.Errorf("unable to fetch OIDC discovery data from issuer: unexpected http response status: %s", response.Status)
}
rawBody, err := ioutil.ReadAll(response.Body)
if err != nil {
return "", fmt.Errorf("unable to fetch OIDC discovery data from issuer: could not read response body: %w", err)
}
var body supervisorOIDCDiscoveryResponse
err = json.Unmarshal(rawBody, &body)
if err != nil {
return "", fmt.Errorf("unable to fetch OIDC discovery data from issuer: could not parse response JSON: %w", err)
}
return body.PinnipedIDPsEndpoint, nil
}
func discoverAllAvailableSupervisorUpstreamIDPs(ctx context.Context, pinnipedIDPsEndpoint string, httpClient *http.Client) ([]pinnipedIDPResponse, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, pinnipedIDPsEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("while forming request to IDP discovery URL: %w", err)
}
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("unable to fetch IDP discovery data from issuer: %w", err)
}
defer func() {
_ = response.Body.Close()
}()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unable to fetch IDP discovery data from issuer: unexpected http response status: %s", response.Status)
}
rawBody, err := ioutil.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("unable to fetch IDP discovery data from issuer: could not read response body: %w", err)
}
var body supervisorIDPsDiscoveryResponse
err = json.Unmarshal(rawBody, &body)
if err != nil {
return nil, fmt.Errorf("unable to fetch IDP discovery data from issuer: could not parse response JSON: %w", err)
}
return body.PinnipedIDPs, nil
}
func selectUpstreamIDP(pinnipedIDPs []pinnipedIDPResponse, idpName, idpType string) (string, string, error) { func selectUpstreamIDP(pinnipedIDPs []pinnipedIDPResponse, idpName, idpType string) (string, string, error) {
pinnipedIDPsString, _ := json.Marshal(pinnipedIDPs) pinnipedIDPsString, _ := json.Marshal(pinnipedIDPs)
switch { switch {

View File

@ -82,8 +82,10 @@ func TestGetKubeconfig(t *testing.T) {
getClientsetErr error getClientsetErr error
conciergeObjects func(string, string) []runtime.Object conciergeObjects func(string, string) []runtime.Object
conciergeReactions []kubetesting.Reactor conciergeReactions []kubetesting.Reactor
discoveryResponse string oidcDiscoveryResponse func(string) string
discoveryStatusCode int oidcDiscoveryStatusCode int
idpsDiscoveryResponse string
idpsDiscoveryStatusCode int
wantLogs func(string, string) []string wantLogs func(string, string) []string
wantError bool wantError bool
wantStdout func(string, string) string wantStdout func(string, string) string
@ -690,7 +692,7 @@ func TestGetKubeconfig(t *testing.T) {
}, },
}, },
{ {
name: "when discovery document 400s", name: "when OIDC discovery document 400s",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml", "--kubeconfig", "./testdata/kubeconfig.yaml",
@ -703,7 +705,7 @@ func TestGetKubeconfig(t *testing.T) {
jwtAuthenticator(issuerCABundle, issuerURL), jwtAuthenticator(issuerCABundle, issuerURL),
} }
}, },
discoveryStatusCode: http.StatusBadRequest, oidcDiscoveryStatusCode: http.StatusBadRequest,
wantLogs: func(issuerCABundle string, issuerURL string) []string { wantLogs: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
`"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`,
@ -718,11 +720,11 @@ func TestGetKubeconfig(t *testing.T) {
}, },
wantError: true, wantError: true,
wantStderr: func(issuerCABundle string, issuerURL string) string { wantStderr: func(issuerCABundle string, issuerURL string) string {
return "Error: unable to fetch discovery data from issuer: unexpected http response status: 400 Bad Request\n" return "Error: unable to fetch OIDC discovery data from issuer: unexpected http response status: 400 Bad Request\n"
}, },
}, },
{ {
name: "when discovery document contains multiple pinniped_idps and no name or type flags are given", name: "when IDP discovery document returns any error",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml", "--kubeconfig", "./testdata/kubeconfig.yaml",
@ -735,9 +737,46 @@ func TestGetKubeconfig(t *testing.T) {
jwtAuthenticator(issuerCABundle, issuerURL), jwtAuthenticator(issuerCABundle, issuerURL),
} }
}, },
discoveryStatusCode: http.StatusOK, oidcDiscoveryResponse: func(issuerURL string) string {
discoveryResponse: here.Docf(`{ return fmt.Sprintf(`{"pinniped_identity_providers_endpoint": "%s/pinniped_identity_providers"}`, issuerURL)
"pinniped_idps": [ },
idpsDiscoveryStatusCode: http.StatusBadRequest,
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"="test-audience"`,
`"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`,
}
},
wantError: true,
wantStderr: func(issuerCABundle string, issuerURL string) string {
return "Error: unable to fetch IDP discovery data from issuer: unexpected http response status: 400 Bad Request\n"
},
},
{
name: "when IDP discovery document contains multiple pinniped_idps and no name or type flags are given",
args: func(issuerCABundle string, issuerURL string) []string {
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
"--skip-validation",
}
},
conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object {
return []runtime.Object{
credentialIssuer(),
jwtAuthenticator(issuerCABundle, issuerURL),
}
},
oidcDiscoveryResponse: func(issuerURL string) string {
return fmt.Sprintf(`{"pinniped_identity_providers_endpoint": "%s/pinniped_identity_providers"}`, issuerURL)
},
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-ldap-idp", "type": "ldap"}, {"name": "some-ldap-idp", "type": "ldap"},
{"name": "some-oidc-idp", "type": "oidc"} {"name": "some-oidc-idp", "type": "oidc"}
] ]
@ -762,7 +801,7 @@ func TestGetKubeconfig(t *testing.T) {
}, },
}, },
{ {
name: "when discovery document is not valid JSON", name: "when OIDC discovery document is not valid JSON",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml", "--kubeconfig", "./testdata/kubeconfig.yaml",
@ -775,8 +814,9 @@ func TestGetKubeconfig(t *testing.T) {
jwtAuthenticator(issuerCABundle, issuerURL), jwtAuthenticator(issuerCABundle, issuerURL),
} }
}, },
discoveryStatusCode: http.StatusOK, oidcDiscoveryResponse: func(issuerURL string) string {
discoveryResponse: "this is not valid JSON", return "this is not valid JSON"
},
wantLogs: func(issuerCABundle string, issuerURL string) []string { wantLogs: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
`"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`,
@ -791,11 +831,46 @@ func TestGetKubeconfig(t *testing.T) {
}, },
wantError: true, wantError: true,
wantStderr: func(issuerCABundle string, issuerURL string) string { wantStderr: func(issuerCABundle string, issuerURL string) string {
return "Error: unable to fetch discovery data from issuer: could not parse response JSON: invalid character 'h' in literal true (expecting 'r')\n" return "Error: unable to fetch OIDC discovery data from issuer: could not parse response JSON: invalid character 'h' in literal true (expecting 'r')\n"
}, },
}, },
{ {
name: "when tls information is missing from jwtauthenticator, test fails because discovery fails", name: "when IDP discovery document is not valid JSON",
args: func(issuerCABundle string, issuerURL string) []string {
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
"--skip-validation",
}
},
conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object {
return []runtime.Object{
credentialIssuer(),
jwtAuthenticator(issuerCABundle, issuerURL),
}
},
oidcDiscoveryResponse: func(issuerURL string) string {
return fmt.Sprintf(`{"pinniped_identity_providers_endpoint": "%s/pinniped_identity_providers"}`, issuerURL)
},
idpsDiscoveryResponse: "this is not valid JSON",
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"="test-audience"`,
`"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`,
}
},
wantError: true,
wantStderr: func(issuerCABundle string, issuerURL string) string {
return "Error: unable to fetch IDP discovery data from issuer: could not parse response JSON: invalid character 'h' in literal true (expecting 'r')\n"
},
},
{
name: "when tls information is missing from jwtauthenticator, errors because OIDC discovery fails",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml", "--kubeconfig", "./testdata/kubeconfig.yaml",
@ -827,7 +902,7 @@ func TestGetKubeconfig(t *testing.T) {
}, },
wantError: true, wantError: true,
wantStderr: func(issuerCABundle string, issuerURL string) string { wantStderr: func(issuerCABundle string, issuerURL string) string {
return fmt.Sprintf("Error: unable to fetch discovery data from issuer: Get \"%s/.well-known/openid-configuration\": x509: certificate signed by unknown authority\n", issuerURL) return fmt.Sprintf("Error: unable to fetch OIDC discovery data from issuer: Get \"%s/.well-known/openid-configuration\": x509: certificate signed by unknown authority\n", issuerURL)
}, },
}, },
{ {
@ -868,6 +943,40 @@ func TestGetKubeconfig(t *testing.T) {
return `Error: while forming request to issuer URL: parse "https%://bad-issuer-url/.well-known/openid-configuration": first path segment in URL cannot contain colon` + "\n" return `Error: while forming request to issuer URL: parse "https%://bad-issuer-url/.well-known/openid-configuration": first path segment in URL cannot contain colon` + "\n"
}, },
}, },
{
name: "when the IDP discovery url is bad",
args: func(issuerCABundle string, issuerURL string) []string {
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
"--skip-validation",
}
},
conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object {
return []runtime.Object{
credentialIssuer(),
jwtAuthenticator(issuerCABundle, issuerURL),
}
},
oidcDiscoveryResponse: func(issuerURL string) string {
return `{"pinniped_identity_providers_endpoint": "https%://illegal_url"}`
},
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"="test-audience"`,
`"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`,
}
},
wantError: true,
wantStderr: func(issuerCABundle string, issuerURL string) string {
return `Error: while forming request to IDP discovery URL: parse "https%://illegal_url": first path segment in URL cannot contain colon` + "\n"
},
},
{ {
name: "supervisor upstream IDP discovery fails to resolve ambiguity when type is specified but name is not", name: "supervisor upstream IDP discovery fails to resolve ambiguity when type is specified but name is not",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
@ -881,8 +990,11 @@ func TestGetKubeconfig(t *testing.T) {
"--upstream-identity-provider-type", "ldap", "--upstream-identity-provider-type", "ldap",
} }
}, },
discoveryResponse: here.Docf(`{ oidcDiscoveryResponse: func(issuerURL string) string {
"pinniped_idps": [ return fmt.Sprintf(`{"pinniped_identity_providers_endpoint": "%s/pinniped_identity_providers"}`, issuerURL)
},
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-ldap-idp", "type": "ldap"}, {"name": "some-ldap-idp", "type": "ldap"},
{"name": "some-other-ldap-idp", "type": "ldap"}, {"name": "some-other-ldap-idp", "type": "ldap"},
{"name": "some-oidc-idp", "type": "oidc"}, {"name": "some-oidc-idp", "type": "oidc"},
@ -909,8 +1021,11 @@ func TestGetKubeconfig(t *testing.T) {
"--upstream-identity-provider-name", "my-idp", "--upstream-identity-provider-name", "my-idp",
} }
}, },
discoveryResponse: here.Docf(`{ oidcDiscoveryResponse: func(issuerURL string) string {
"pinniped_idps": [ return fmt.Sprintf(`{"pinniped_identity_providers_endpoint": "%s/pinniped_identity_providers"}`, issuerURL)
},
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "my-idp", "type": "ldap"}, {"name": "my-idp", "type": "ldap"},
{"name": "my-idp", "type": "oidc"}, {"name": "my-idp", "type": "oidc"},
{"name": "some-other-oidc-idp", "type": "oidc"} {"name": "some-other-oidc-idp", "type": "oidc"}
@ -936,8 +1051,11 @@ func TestGetKubeconfig(t *testing.T) {
"--upstream-identity-provider-type", "ldap", "--upstream-identity-provider-type", "ldap",
} }
}, },
discoveryResponse: here.Docf(`{ oidcDiscoveryResponse: func(issuerURL string) string {
"pinniped_idps": [ return fmt.Sprintf(`{"pinniped_identity_providers_endpoint": "%s/pinniped_identity_providers"}`, issuerURL)
},
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-oidc-idp", "type": "oidc"}, {"name": "some-oidc-idp", "type": "oidc"},
{"name": "some-other-oidc-idp", "type": "oidc"} {"name": "some-other-oidc-idp", "type": "oidc"}
] ]
@ -961,8 +1079,11 @@ func TestGetKubeconfig(t *testing.T) {
"--upstream-identity-provider-name", "my-nonexistent-idp", "--upstream-identity-provider-name", "my-nonexistent-idp",
} }
}, },
discoveryResponse: here.Docf(`{ oidcDiscoveryResponse: func(issuerURL string) string {
"pinniped_idps": [ return fmt.Sprintf(`{"pinniped_identity_providers_endpoint": "%s/pinniped_identity_providers"}`, issuerURL)
},
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-oidc-idp", "type": "oidc"}, {"name": "some-oidc-idp", "type": "oidc"},
{"name": "some-other-oidc-idp", "type": "oidc"} {"name": "some-other-oidc-idp", "type": "oidc"}
] ]
@ -1464,7 +1585,7 @@ func TestGetKubeconfig(t *testing.T) {
}, },
}, },
{ {
name: "Find LDAP idp in discovery document, output ldap related flags", name: "Find LDAP IDP in IDP discovery document, output ldap related flags",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml", "--kubeconfig", "./testdata/kubeconfig.yaml",
@ -1477,8 +1598,13 @@ func TestGetKubeconfig(t *testing.T) {
jwtAuthenticator(issuerCABundle, issuerURL), jwtAuthenticator(issuerCABundle, issuerURL),
} }
}, },
discoveryResponse: here.Docf(`{ oidcDiscoveryResponse: func(issuerURL string) string {
"pinniped_idps": [{"name": "some-ldap-idp", "type": "ldap"}] return fmt.Sprintf(`{"pinniped_identity_providers_endpoint": "%s/pinniped_identity_providers"}`, issuerURL)
},
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-ldap-idp", "type": "ldap"}
]
}`), }`),
wantLogs: func(issuerCABundle string, issuerURL string) []string { wantLogs: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
@ -1538,7 +1664,7 @@ func TestGetKubeconfig(t *testing.T) {
}, },
}, },
{ {
name: "Find OIDC idp in discovery document, output oidc related flags", name: "Find OIDC IDP in IDP discovery document, output oidc related flags",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml", "--kubeconfig", "./testdata/kubeconfig.yaml",
@ -1551,8 +1677,13 @@ func TestGetKubeconfig(t *testing.T) {
jwtAuthenticator(issuerCABundle, issuerURL), jwtAuthenticator(issuerCABundle, issuerURL),
} }
}, },
discoveryResponse: here.Docf(`{ oidcDiscoveryResponse: func(issuerURL string) string {
"pinniped_idps": [{"name": "some-oidc-idp", "type": "oidc"}] return fmt.Sprintf(`{"pinniped_identity_providers_endpoint": "%s/pinniped_identity_providers"}`, issuerURL)
},
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-oidc-idp", "type": "oidc"}
]
}`), }`),
wantLogs: func(issuerCABundle string, issuerURL string) []string { wantLogs: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
@ -1612,7 +1743,7 @@ func TestGetKubeconfig(t *testing.T) {
}, },
}, },
{ {
name: "empty idp list in discovery document", name: "empty IDP list in IDP discovery document",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml", "--kubeconfig", "./testdata/kubeconfig.yaml",
@ -1625,8 +1756,11 @@ func TestGetKubeconfig(t *testing.T) {
jwtAuthenticator(issuerCABundle, issuerURL), jwtAuthenticator(issuerCABundle, issuerURL),
} }
}, },
discoveryResponse: here.Docf(`{ oidcDiscoveryResponse: func(issuerURL string) string {
"pinniped_idps": [] return fmt.Sprintf(`{"pinniped_identity_providers_endpoint": "%s/pinniped_identity_providers"}`, issuerURL)
},
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": []
}`), }`),
wantLogs: func(issuerCABundle string, issuerURL string) []string { wantLogs: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
@ -1684,7 +1818,7 @@ func TestGetKubeconfig(t *testing.T) {
}, },
}, },
{ {
name: "when discovery document 404s, dont set idp related flags", name: "IDP discovery endpoint is not listed in OIDC discovery document",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml", "--kubeconfig", "./testdata/kubeconfig.yaml",
@ -1697,7 +1831,80 @@ func TestGetKubeconfig(t *testing.T) {
jwtAuthenticator(issuerCABundle, issuerURL), jwtAuthenticator(issuerCABundle, issuerURL),
} }
}, },
discoveryStatusCode: http.StatusNotFound, oidcDiscoveryResponse: func(issuerURL string) string {
return `{"other_field": "other_value"}`
},
idpsDiscoveryStatusCode: http.StatusBadRequest, // IDPs endpoint shouldn't be called by this test
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"="test-audience"`,
`"level"=0 "msg"="discovered OIDC CA bundle" "roots"=1`,
}
},
wantStdout: func(issuerCABundle string, issuerURL string) string {
return here.Docf(`
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ==
server: https://fake-server-url-value
name: kind-cluster-pinniped
contexts:
- context:
cluster: kind-cluster-pinniped
user: kind-user-pinniped
name: kind-context-pinniped
current-context: kind-context-pinniped
kind: Config
preferences: {}
users:
- name: kind-user-pinniped
user:
exec:
apiVersion: client.authentication.k8s.io/v1beta1
args:
- login
- oidc
- --enable-concierge
- --concierge-api-group-suffix=pinniped.dev
- --concierge-authenticator-name=test-authenticator
- --concierge-authenticator-type=jwt
- --concierge-endpoint=https://fake-server-url-value
- --concierge-ca-bundle-data=ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ==
- --issuer=%s
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --ca-bundle-data=%s
- --request-audience=test-audience
command: '.../path/to/pinniped'
env: []
provideClusterInfo: true
`,
issuerURL,
base64.StdEncoding.EncodeToString([]byte(issuerCABundle)))
},
},
{
name: "when OIDC discovery document 404s, dont set idp related flags",
args: func(issuerCABundle string, issuerURL string) []string {
return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml",
"--skip-validation",
}
},
conciergeObjects: func(issuerCABundle string, issuerURL string) []runtime.Object {
return []runtime.Object{
credentialIssuer(),
jwtAuthenticator(issuerCABundle, issuerURL),
}
},
oidcDiscoveryStatusCode: http.StatusNotFound,
wantLogs: func(issuerCABundle string, issuerURL string) []string { wantLogs: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
`"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`,
@ -1769,7 +1976,7 @@ func TestGetKubeconfig(t *testing.T) {
jwtAuthenticator(issuerCABundle, issuerURL), jwtAuthenticator(issuerCABundle, issuerURL),
} }
}, },
discoveryStatusCode: http.StatusNotFound, oidcDiscoveryStatusCode: http.StatusNotFound,
wantLogs: func(issuerCABundle string, issuerURL string) []string { wantLogs: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
`"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`, `"level"=0 "msg"="discovered CredentialIssuer" "name"="test-credential-issuer"`,
@ -1828,7 +2035,7 @@ func TestGetKubeconfig(t *testing.T) {
}, },
}, },
{ {
name: "when upstream idp related flags are sent, pass them through even when discovery shows a different idp", name: "when upstream IDP related flags are sent, pass them through even when IDP discovery shows a different IDP",
args: func(issuerCABundle string, issuerURL string) []string { args: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
"--kubeconfig", "./testdata/kubeconfig.yaml", "--kubeconfig", "./testdata/kubeconfig.yaml",
@ -1843,8 +2050,13 @@ func TestGetKubeconfig(t *testing.T) {
jwtAuthenticator(issuerCABundle, issuerURL), jwtAuthenticator(issuerCABundle, issuerURL),
} }
}, },
discoveryResponse: here.Docf(`{ oidcDiscoveryResponse: func(issuerURL string) string {
"pinniped_idps": [{"name": "some-other-ldap-idp", "type": "ldap"}] return fmt.Sprintf(`{"pinniped_identity_providers_endpoint": "%s/pinniped_identity_providers"}`, issuerURL)
},
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-other-ldap-idp", "type": "ldap"}
]
}`), }`),
wantLogs: func(issuerCABundle string, issuerURL string) []string { wantLogs: func(issuerCABundle string, issuerURL string) []string {
return []string{ return []string{
@ -1915,8 +2127,13 @@ func TestGetKubeconfig(t *testing.T) {
"--oidc-ca-bundle", f.Name(), "--oidc-ca-bundle", f.Name(),
} }
}, },
discoveryResponse: here.Docf(`{ oidcDiscoveryResponse: func(issuerURL string) string {
"pinniped_idps": [{"name": "some-ldap-idp", "type": "ldap"}] return fmt.Sprintf(`{"pinniped_identity_providers_endpoint": "%s/pinniped_identity_providers"}`, issuerURL)
},
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-ldap-idp", "type": "ldap"}
]
}`), }`),
wantStdout: func(issuerCABundle string, issuerURL string) string { wantStdout: func(issuerCABundle string, issuerURL string) string {
return here.Docf(` return here.Docf(`
@ -1969,8 +2186,11 @@ func TestGetKubeconfig(t *testing.T) {
"--upstream-identity-provider-type", "ldap", "--upstream-identity-provider-type", "ldap",
} }
}, },
discoveryResponse: here.Docf(`{ oidcDiscoveryResponse: func(issuerURL string) string {
"pinniped_idps": [ return fmt.Sprintf(`{"pinniped_identity_providers_endpoint": "%s/pinniped_identity_providers"}`, issuerURL)
},
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-ldap-idp", "type": "ldap"}, {"name": "some-ldap-idp", "type": "ldap"},
{"name": "some-oidc-idp", "type": "oidc"}, {"name": "some-oidc-idp", "type": "oidc"},
{"name": "some-other-oidc-idp", "type": "oidc"} {"name": "some-other-oidc-idp", "type": "oidc"}
@ -2027,8 +2247,11 @@ func TestGetKubeconfig(t *testing.T) {
"--upstream-identity-provider-name", "some-ldap-idp", "--upstream-identity-provider-name", "some-ldap-idp",
} }
}, },
discoveryResponse: here.Docf(`{ oidcDiscoveryResponse: func(issuerURL string) string {
"pinniped_idps": [ return fmt.Sprintf(`{"pinniped_identity_providers_endpoint": "%s/pinniped_identity_providers"}`, issuerURL)
},
idpsDiscoveryResponse: here.Docf(`{
"pinniped_identity_providers": [
{"name": "some-ldap-idp", "type": "ldap"}, {"name": "some-ldap-idp", "type": "ldap"},
{"name": "some-oidc-idp", "type": "oidc"}, {"name": "some-oidc-idp", "type": "oidc"},
{"name": "some-other-oidc-idp", "type": "oidc"} {"name": "some-other-oidc-idp", "type": "oidc"}
@ -2076,22 +2299,36 @@ func TestGetKubeconfig(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
tt := tt tt := tt
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
var issuerEndpointPtr *string
issuerCABundle, issuerEndpoint := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) { issuerCABundle, issuerEndpoint := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/openid-configuration" { switch r.URL.Path {
jsonResponseBody := tt.discoveryResponse case "/.well-known/openid-configuration":
if tt.discoveryResponse == "" { jsonResponseBody := "{}"
jsonResponseBody = "{}" if tt.oidcDiscoveryResponse != nil {
jsonResponseBody = tt.oidcDiscoveryResponse(*issuerEndpointPtr)
} }
if tt.discoveryStatusCode == 0 { if tt.oidcDiscoveryStatusCode == 0 {
tt.discoveryStatusCode = http.StatusOK tt.oidcDiscoveryStatusCode = http.StatusOK
} }
w.WriteHeader(tt.discoveryStatusCode) w.WriteHeader(tt.oidcDiscoveryStatusCode)
_, err = w.Write([]byte(jsonResponseBody)) _, err = w.Write([]byte(jsonResponseBody))
require.NoError(t, err) require.NoError(t, err)
} else { case "/pinniped_identity_providers":
t.Fatalf("tried to call issuer at a path that wasn't the discovery endpoint.") jsonResponseBody := tt.idpsDiscoveryResponse
if tt.idpsDiscoveryResponse == "" {
jsonResponseBody = "{}"
}
if tt.idpsDiscoveryStatusCode == 0 {
tt.idpsDiscoveryStatusCode = http.StatusOK
}
w.WriteHeader(tt.idpsDiscoveryStatusCode)
_, err = w.Write([]byte(jsonResponseBody))
require.NoError(t, err)
default:
t.Fatalf("tried to call issuer at a path that wasn't one of the expected discovery endpoints.")
} }
}) })
issuerEndpointPtr = &issuerEndpoint
testLog := testlogger.New(t) testLog := testlogger.New(t)
cmd := kubeconfigCommand(kubeconfigDeps{ cmd := kubeconfigCommand(kubeconfigDeps{

View File

@ -8,16 +8,10 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"net/http" "net/http"
"sort"
"go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc"
) )
const (
idpDiscoveryTypeLDAP = "ldap"
idpDiscoveryTypeOIDC = "oidc"
)
// Metadata holds all fields (that we care about) from the OpenID Provider Metadata section in the // Metadata holds all fields (that we care about) from the OpenID Provider Metadata section in the
// OpenID Connect Discovery specification: // OpenID Connect Discovery specification:
// https://openid.net/specs/openid-connect-discovery-1_0.html#rfc.section.3. // https://openid.net/specs/openid-connect-discovery-1_0.html#rfc.section.3.
@ -46,7 +40,7 @@ type Metadata struct {
// vvv Custom vvv // vvv Custom vvv
IDPs []IdentityProviderMetadata `json:"pinniped_idps"` PinnipedIDPsEndpoint string `json:"pinniped_identity_providers_endpoint"`
// ^^^ Custom ^^^ // ^^^ Custom ^^^
} }
@ -57,14 +51,31 @@ type IdentityProviderMetadata struct {
} }
// NewHandler returns an http.Handler that serves an OIDC discovery endpoint. // NewHandler returns an http.Handler that serves an OIDC discovery endpoint.
func NewHandler(issuerURL string, upstreamIDPs oidc.UpstreamIdentityProvidersLister) http.Handler { func NewHandler(issuerURL string) http.Handler {
oidcConfig := Metadata{
Issuer: issuerURL,
AuthorizationEndpoint: issuerURL + oidc.AuthorizationEndpointPath,
TokenEndpoint: issuerURL + oidc.TokenEndpointPath,
JWKSURI: issuerURL + oidc.JWKSEndpointPath,
PinnipedIDPsEndpoint: issuerURL + oidc.PinnipedIDPsPath,
ResponseTypesSupported: []string{"code"},
SubjectTypesSupported: []string{"public"},
IDTokenSigningAlgValuesSupported: []string{"ES256"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic"},
ScopesSupported: []string{"openid", "offline"},
ClaimsSupported: []string{"groups"},
}
var b bytes.Buffer
encodeErr := json.NewEncoder(&b).Encode(&oidcConfig)
encodedMetadata := b.Bytes()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
http.Error(w, `Method not allowed (try GET)`, http.StatusMethodNotAllowed) http.Error(w, `Method not allowed (try GET)`, http.StatusMethodNotAllowed)
return return
} }
encodedMetadata, encodeErr := metadata(issuerURL, upstreamIDPs)
if encodeErr != nil { if encodeErr != nil {
http.Error(w, encodeErr.Error(), http.StatusInternalServerError) http.Error(w, encodeErr.Error(), http.StatusInternalServerError)
return return
@ -77,38 +88,3 @@ func NewHandler(issuerURL string, upstreamIDPs oidc.UpstreamIdentityProvidersLis
} }
}) })
} }
func metadata(issuerURL string, upstreamIDPs oidc.UpstreamIdentityProvidersLister) ([]byte, error) {
oidcConfig := Metadata{
Issuer: issuerURL,
AuthorizationEndpoint: issuerURL + oidc.AuthorizationEndpointPath,
TokenEndpoint: issuerURL + oidc.TokenEndpointPath,
JWKSURI: issuerURL + oidc.JWKSEndpointPath,
ResponseTypesSupported: []string{"code"},
SubjectTypesSupported: []string{"public"},
IDTokenSigningAlgValuesSupported: []string{"ES256"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic"},
ScopesSupported: []string{"openid", "offline"},
ClaimsSupported: []string{"groups"},
IDPs: []IdentityProviderMetadata{},
}
// The cache of IDPs could change at any time, so always recalculate the list.
for _, provider := range upstreamIDPs.GetLDAPIdentityProviders() {
oidcConfig.IDPs = append(oidcConfig.IDPs, IdentityProviderMetadata{Name: provider.GetName(), Type: idpDiscoveryTypeLDAP})
}
for _, provider := range upstreamIDPs.GetOIDCIdentityProviders() {
oidcConfig.IDPs = append(oidcConfig.IDPs, IdentityProviderMetadata{Name: provider.GetName(), Type: idpDiscoveryTypeOIDC})
}
// Nobody like an API that changes the results unnecessarily. :)
sort.SliceStable(oidcConfig.IDPs, func(i, j int) bool {
return oidcConfig.IDPs[i].Name < oidcConfig.IDPs[j].Name
})
var b bytes.Buffer
encodeErr := json.NewEncoder(&b).Encode(&oidcConfig)
encodedMetadata := b.Bytes()
return encodedMetadata, encodeErr
}

View File

@ -9,10 +9,6 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/testutil/oidctestutil"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc"
@ -28,8 +24,7 @@ func TestDiscovery(t *testing.T) {
wantStatus int wantStatus int
wantContentType string wantContentType string
wantFirstResponseBodyJSON interface{} wantBodyJSON interface{}
wantSecondResponseBodyJSON interface{}
wantBodyString string wantBodyString string
}{ }{
{ {
@ -39,43 +34,18 @@ func TestDiscovery(t *testing.T) {
path: "/some/path" + oidc.WellKnownEndpointPath, path: "/some/path" + oidc.WellKnownEndpointPath,
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantContentType: "application/json", wantContentType: "application/json",
wantFirstResponseBodyJSON: &Metadata{ wantBodyJSON: &Metadata{
Issuer: "https://some-issuer.com/some/path", Issuer: "https://some-issuer.com/some/path",
AuthorizationEndpoint: "https://some-issuer.com/some/path/oauth2/authorize", AuthorizationEndpoint: "https://some-issuer.com/some/path/oauth2/authorize",
TokenEndpoint: "https://some-issuer.com/some/path/oauth2/token", TokenEndpoint: "https://some-issuer.com/some/path/oauth2/token",
JWKSURI: "https://some-issuer.com/some/path/jwks.json", JWKSURI: "https://some-issuer.com/some/path/jwks.json",
PinnipedIDPsEndpoint: "https://some-issuer.com/some/path/pinniped_identity_providers",
ResponseTypesSupported: []string{"code"}, ResponseTypesSupported: []string{"code"},
SubjectTypesSupported: []string{"public"}, SubjectTypesSupported: []string{"public"},
IDTokenSigningAlgValuesSupported: []string{"ES256"}, IDTokenSigningAlgValuesSupported: []string{"ES256"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic"}, TokenEndpointAuthMethodsSupported: []string{"client_secret_basic"},
ScopesSupported: []string{"openid", "offline"}, ScopesSupported: []string{"openid", "offline"},
ClaimsSupported: []string{"groups"}, ClaimsSupported: []string{"groups"},
IDPs: []IdentityProviderMetadata{
{Name: "a-some-ldap-idp", Type: "ldap"},
{Name: "a-some-oidc-idp", Type: "oidc"},
{Name: "x-some-idp", Type: "ldap"},
{Name: "x-some-idp", Type: "oidc"},
{Name: "z-some-ldap-idp", Type: "ldap"},
{Name: "z-some-oidc-idp", Type: "oidc"},
},
},
wantSecondResponseBodyJSON: &Metadata{
Issuer: "https://some-issuer.com/some/path",
AuthorizationEndpoint: "https://some-issuer.com/some/path/oauth2/authorize",
TokenEndpoint: "https://some-issuer.com/some/path/oauth2/token",
JWKSURI: "https://some-issuer.com/some/path/jwks.json",
ResponseTypesSupported: []string{"code"},
SubjectTypesSupported: []string{"public"},
IDTokenSigningAlgValuesSupported: []string{"ES256"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic"},
ScopesSupported: []string{"openid", "offline"},
ClaimsSupported: []string{"groups"},
IDPs: []IdentityProviderMetadata{
{Name: "some-other-ldap-idp-1", Type: "ldap"},
{Name: "some-other-ldap-idp-2", Type: "ldap"},
{Name: "some-other-oidc-idp-1", Type: "oidc"},
{Name: "some-other-oidc-idp-2", Type: "oidc"},
},
}, },
}, },
{ {
@ -91,16 +61,7 @@ func TestDiscovery(t *testing.T) {
for _, test := range tests { for _, test := range tests {
test := test test := test
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
idpLister := oidctestutil.NewUpstreamIDPListerBuilder(). handler := NewHandler(test.issuer)
WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "z-some-oidc-idp"}).
WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "x-some-idp"}).
WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "a-some-ldap-idp"}).
WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "a-some-oidc-idp"}).
WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "z-some-ldap-idp"}).
WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "x-some-idp"}).
Build()
handler := NewHandler(test.issuer, idpLister)
req := httptest.NewRequest(test.method, test.path, nil) req := httptest.NewRequest(test.method, test.path, nil)
rsp := httptest.NewRecorder() rsp := httptest.NewRecorder()
handler.ServeHTTP(rsp, req) handler.ServeHTTP(rsp, req)
@ -109,36 +70,8 @@ func TestDiscovery(t *testing.T) {
require.Equal(t, test.wantContentType, rsp.Header().Get("Content-Type")) require.Equal(t, test.wantContentType, rsp.Header().Get("Content-Type"))
if test.wantFirstResponseBodyJSON != nil { if test.wantBodyJSON != nil {
wantJSON, err := json.Marshal(test.wantFirstResponseBodyJSON) wantJSON, err := json.Marshal(test.wantBodyJSON)
require.NoError(t, err)
require.JSONEq(t, string(wantJSON), rsp.Body.String())
}
if test.wantBodyString != "" {
require.Equal(t, test.wantBodyString, rsp.Body.String())
}
// Change the list of IDPs in the cache.
idpLister.SetLDAPIdentityProviders([]provider.UpstreamLDAPIdentityProviderI{
&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "some-other-ldap-idp-1"},
&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "some-other-ldap-idp-2"},
})
idpLister.SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI{
&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "some-other-oidc-idp-1"},
&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "some-other-oidc-idp-2"},
})
// Make the same request to the same handler instance again, and expect different results.
rsp = httptest.NewRecorder()
handler.ServeHTTP(rsp, req)
require.Equal(t, test.wantStatus, rsp.Code)
require.Equal(t, test.wantContentType, rsp.Header().Get("Content-Type"))
if test.wantFirstResponseBodyJSON != nil {
wantJSON, err := json.Marshal(test.wantSecondResponseBodyJSON)
require.NoError(t, err) require.NoError(t, err)
require.JSONEq(t, string(wantJSON), rsp.Body.String()) require.JSONEq(t, string(wantJSON), rsp.Body.String())
} }

View File

@ -0,0 +1,75 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package idpdiscovery provides a handler for the upstream IDP discovery endpoint.
package idpdiscovery
import (
"bytes"
"encoding/json"
"net/http"
"sort"
"go.pinniped.dev/internal/oidc"
)
const (
idpDiscoveryTypeLDAP = "ldap"
idpDiscoveryTypeOIDC = "oidc"
)
type response struct {
IDPs []identityProviderResponse `json:"pinniped_identity_providers"`
}
type identityProviderResponse struct {
Name string `json:"name"`
Type string `json:"type"`
}
// NewHandler returns an http.Handler that serves the upstream IDP discovery endpoint.
func NewHandler(upstreamIDPs oidc.UpstreamIdentityProvidersLister) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `Method not allowed (try GET)`, http.StatusMethodNotAllowed)
return
}
encodedMetadata, encodeErr := responseAsJSON(upstreamIDPs)
if encodeErr != nil {
http.Error(w, encodeErr.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(encodedMetadata); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
func responseAsJSON(upstreamIDPs oidc.UpstreamIdentityProvidersLister) ([]byte, error) {
r := response{
IDPs: []identityProviderResponse{},
}
// The cache of IDPs could change at any time, so always recalculate the list.
for _, provider := range upstreamIDPs.GetLDAPIdentityProviders() {
r.IDPs = append(r.IDPs, identityProviderResponse{Name: provider.GetName(), Type: idpDiscoveryTypeLDAP})
}
for _, provider := range upstreamIDPs.GetOIDCIdentityProviders() {
r.IDPs = append(r.IDPs, identityProviderResponse{Name: provider.GetName(), Type: idpDiscoveryTypeOIDC})
}
// Nobody like an API that changes the results unnecessarily. :)
sort.SliceStable(r.IDPs, func(i, j int) bool {
return r.IDPs[i].Name < r.IDPs[j].Name
})
var b bytes.Buffer
encodeErr := json.NewEncoder(&b).Encode(&r)
encodedMetadata := b.Bytes()
return encodedMetadata, encodeErr
}

View File

@ -0,0 +1,126 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package idpdiscovery
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/testutil/oidctestutil"
)
func TestIDPDiscovery(t *testing.T) {
tests := []struct {
name string
method string
path string
wantStatus int
wantContentType string
wantFirstResponseBodyJSON interface{}
wantSecondResponseBodyJSON interface{}
wantBodyString string
}{
{
name: "happy path",
method: http.MethodGet,
path: "/some/path" + oidc.WellKnownEndpointPath,
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantFirstResponseBodyJSON: &response{
IDPs: []identityProviderResponse{
{Name: "a-some-ldap-idp", Type: "ldap"},
{Name: "a-some-oidc-idp", Type: "oidc"},
{Name: "x-some-idp", Type: "ldap"},
{Name: "x-some-idp", Type: "oidc"},
{Name: "z-some-ldap-idp", Type: "ldap"},
{Name: "z-some-oidc-idp", Type: "oidc"},
},
},
wantSecondResponseBodyJSON: &response{
IDPs: []identityProviderResponse{
{Name: "some-other-ldap-idp-1", Type: "ldap"},
{Name: "some-other-ldap-idp-2", Type: "ldap"},
{Name: "some-other-oidc-idp-1", Type: "oidc"},
{Name: "some-other-oidc-idp-2", Type: "oidc"},
},
},
},
{
name: "bad method",
method: http.MethodPost,
path: oidc.WellKnownEndpointPath,
wantStatus: http.StatusMethodNotAllowed,
wantContentType: "text/plain; charset=utf-8",
wantBodyString: "Method not allowed (try GET)\n",
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
idpLister := oidctestutil.NewUpstreamIDPListerBuilder().
WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "z-some-oidc-idp"}).
WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "x-some-idp"}).
WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "a-some-ldap-idp"}).
WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "a-some-oidc-idp"}).
WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "z-some-ldap-idp"}).
WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "x-some-idp"}).
Build()
handler := NewHandler(idpLister)
req := httptest.NewRequest(test.method, test.path, nil)
rsp := httptest.NewRecorder()
handler.ServeHTTP(rsp, req)
require.Equal(t, test.wantStatus, rsp.Code)
require.Equal(t, test.wantContentType, rsp.Header().Get("Content-Type"))
if test.wantFirstResponseBodyJSON != nil {
wantJSON, err := json.Marshal(test.wantFirstResponseBodyJSON)
require.NoError(t, err)
require.JSONEq(t, string(wantJSON), rsp.Body.String())
}
if test.wantBodyString != "" {
require.Equal(t, test.wantBodyString, rsp.Body.String())
}
// Change the list of IDPs in the cache.
idpLister.SetLDAPIdentityProviders([]provider.UpstreamLDAPIdentityProviderI{
&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "some-other-ldap-idp-1"},
&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "some-other-ldap-idp-2"},
})
idpLister.SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI{
&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "some-other-oidc-idp-1"},
&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "some-other-oidc-idp-2"},
})
// Make the same request to the same handler instance again, and expect different results.
rsp = httptest.NewRecorder()
handler.ServeHTTP(rsp, req)
require.Equal(t, test.wantStatus, rsp.Code)
require.Equal(t, test.wantContentType, rsp.Header().Get("Content-Type"))
if test.wantFirstResponseBodyJSON != nil {
wantJSON, err := json.Marshal(test.wantSecondResponseBodyJSON)
require.NoError(t, err)
require.JSONEq(t, string(wantJSON), rsp.Body.String())
}
if test.wantBodyString != "" {
require.Equal(t, test.wantBodyString, rsp.Body.String())
}
})
}
}

View File

@ -24,6 +24,7 @@ const (
TokenEndpointPath = "/oauth2/token" //nolint:gosec // ignore lint warning that this is a credential TokenEndpointPath = "/oauth2/token" //nolint:gosec // ignore lint warning that this is a credential
CallbackEndpointPath = "/callback" CallbackEndpointPath = "/callback"
JWKSEndpointPath = "/jwks.json" JWKSEndpointPath = "/jwks.json"
PinnipedIDPsPath = "/pinniped_identity_providers"
) )
const ( const (

View File

@ -16,6 +16,7 @@ import (
"go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/csrftoken"
"go.pinniped.dev/internal/oidc/discovery" "go.pinniped.dev/internal/oidc/discovery"
"go.pinniped.dev/internal/oidc/dynamiccodec" "go.pinniped.dev/internal/oidc/dynamiccodec"
"go.pinniped.dev/internal/oidc/idpdiscovery"
"go.pinniped.dev/internal/oidc/jwks" "go.pinniped.dev/internal/oidc/jwks"
"go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/oidc/token" "go.pinniped.dev/internal/oidc/token"
@ -102,10 +103,12 @@ func (m *Manager) SetProviders(federationDomains ...*provider.FederationDomainIs
wrapGetter(incomingProvider.Issuer(), m.secretCache.GetStateEncoderBlockKey), wrapGetter(incomingProvider.Issuer(), m.secretCache.GetStateEncoderBlockKey),
) )
m.providerHandlers[(issuerHostWithPath + oidc.WellKnownEndpointPath)] = discovery.NewHandler(issuer, m.upstreamIDPs) m.providerHandlers[(issuerHostWithPath + oidc.WellKnownEndpointPath)] = discovery.NewHandler(issuer)
m.providerHandlers[(issuerHostWithPath + oidc.JWKSEndpointPath)] = jwks.NewHandler(issuer, m.dynamicJWKSProvider) m.providerHandlers[(issuerHostWithPath + oidc.JWKSEndpointPath)] = jwks.NewHandler(issuer, m.dynamicJWKSProvider)
m.providerHandlers[(issuerHostWithPath + oidc.PinnipedIDPsPath)] = idpdiscovery.NewHandler(m.upstreamIDPs)
m.providerHandlers[(issuerHostWithPath + oidc.AuthorizationEndpointPath)] = auth.NewHandler( m.providerHandlers[(issuerHostWithPath + oidc.AuthorizationEndpointPath)] = auth.NewHandler(
issuer, issuer,
m.upstreamIDPs, m.upstreamIDPs,

View File

@ -7,6 +7,7 @@ import (
"context" "context"
"crypto/ecdsa" "crypto/ecdsa"
"encoding/json" "encoding/json"
"fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -70,7 +71,7 @@ func TestManager(t *testing.T) {
return req return req
} }
requireDiscoveryRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedIssuer, expectedIDPName, expectedIDPType string) { requireDiscoveryRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedIssuer string) {
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.WellKnownEndpointPath+requestURLSuffix)) subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.WellKnownEndpointPath+requestURLSuffix))
@ -85,9 +86,24 @@ func TestManager(t *testing.T) {
err = json.Unmarshal(responseBody, &parsedDiscoveryResult) err = json.Unmarshal(responseBody, &parsedDiscoveryResult)
r.NoError(err) r.NoError(err)
r.Equal(expectedIssuer, parsedDiscoveryResult.Issuer) r.Equal(expectedIssuer, parsedDiscoveryResult.Issuer)
r.Len(parsedDiscoveryResult.IDPs, 1) r.Equal(parsedDiscoveryResult.PinnipedIDPsEndpoint, expectedIssuer+oidc.PinnipedIDPsPath)
r.Equal(expectedIDPName, parsedDiscoveryResult.IDPs[0].Name) }
r.Equal(expectedIDPType, parsedDiscoveryResult.IDPs[0].Type)
requirePinnipedIDPsDiscoveryRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedIDPName, expectedIDPType string) {
recorder := httptest.NewRecorder()
subject.ServeHTTP(recorder, newGetRequest(requestIssuer+oidc.PinnipedIDPsPath+requestURLSuffix))
r.False(fallbackHandlerWasCalled)
// Minimal check to ensure that the right IDP discovery endpoint was called
r.Equal(http.StatusOK, recorder.Code)
responseBody, err := ioutil.ReadAll(recorder.Body)
r.NoError(err)
r.Equal(
fmt.Sprintf(`{"pinniped_identity_providers":[{"name":"%s","type":"%s"}]}`+"\n", expectedIDPName, expectedIDPType),
string(responseBody),
)
} }
requireAuthorizationRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedRedirectLocationPrefix string) (string, string) { requireAuthorizationRequestToBeHandled := func(requestIssuer, requestURLSuffix, expectedRedirectLocationPrefix string) (string, string) {
@ -289,14 +305,23 @@ func TestManager(t *testing.T) {
} }
requireRoutesMatchingRequestsToAppropriateProvider := func() { requireRoutesMatchingRequestsToAppropriateProvider := func() {
requireDiscoveryRequestToBeHandled(issuer1, "", issuer1, upstreamIDPName, upstreamIDPType) requireDiscoveryRequestToBeHandled(issuer1, "", issuer1)
requireDiscoveryRequestToBeHandled(issuer2, "", issuer2, upstreamIDPName, upstreamIDPType) requireDiscoveryRequestToBeHandled(issuer2, "", issuer2)
requireDiscoveryRequestToBeHandled(issuer2, "?some=query", issuer2, upstreamIDPName, upstreamIDPType) requireDiscoveryRequestToBeHandled(issuer2, "?some=query", issuer2)
// Hostnames are case-insensitive, so test that we can handle that. // Hostnames are case-insensitive, so test that we can handle that.
requireDiscoveryRequestToBeHandled(issuer1DifferentCaseHostname, "", issuer1, upstreamIDPName, upstreamIDPType) requireDiscoveryRequestToBeHandled(issuer1DifferentCaseHostname, "", issuer1)
requireDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "", issuer2, upstreamIDPName, upstreamIDPType) requireDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "", issuer2)
requireDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "?some=query", issuer2, upstreamIDPName, upstreamIDPType) requireDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "?some=query", issuer2)
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer1, "", upstreamIDPName, upstreamIDPType)
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2, "", upstreamIDPName, upstreamIDPType)
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2, "?some=query", upstreamIDPName, upstreamIDPType)
// Hostnames are case-insensitive, so test that we can handle that.
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer1DifferentCaseHostname, "", upstreamIDPName, upstreamIDPType)
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "", upstreamIDPName, upstreamIDPType)
requirePinnipedIDPsDiscoveryRequestToBeHandled(issuer2DifferentCaseHostname, "?some=query", upstreamIDPName, upstreamIDPType)
issuer1JWKS := requireJWKSRequestToBeHandled(issuer1, "", issuer1KeyID) issuer1JWKS := requireJWKSRequestToBeHandled(issuer1, "", issuer1KeyID)
issuer2JWKS := requireJWKSRequestToBeHandled(issuer2, "", issuer2KeyID) issuer2JWKS := requireJWKSRequestToBeHandled(issuer2, "", issuer2KeyID)