Filip Troníček 624c79f9f7
Respond to /idp/keys with JSON (#17789)
* Set JSON mimetype for `/idp/keys`

* Fix typos

* Test for header presence

* Assert JSON for ` /.well-known/openid-configuration` as well
2023-05-31 14:45:05 +08:00

193 lines
5.6 KiB
Go

// Copyright (c) 2023 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License.AGPL.txt in the project root for license information.
package identityprovider
import (
"context"
"crypto"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/golang-jwt/jwt/v5"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/zitadel/oidc/pkg/oidc"
"gopkg.in/square/go-jose.v2"
)
const (
issuerBaseURL = "https://api.gitpod.io/idp"
)
func TestRouter(t *testing.T) {
type Expectation struct {
Error string
Response string
}
tests := []struct {
Name string
Expectation Expectation
ResponseExpectation func(*Service) string
ExpectedHeaders map[string]string
Path string
}{
{
Name: "OIDC discovery",
Path: oidc.DiscoveryEndpoint,
Expectation: Expectation{
Response: `{"issuer":"https://api.gitpod.io/idp","authorization_endpoint":"https://api.gitpod.io/idp/not-supported","token_endpoint":"https://api.gitpod.io/idp/not-supported","introspection_endpoint":"https://api.gitpod.io/idp/not-supported","userinfo_endpoint":"https://api.gitpod.io/idp/not-supported","revocation_endpoint":"https://api.gitpod.io/idp/not-supported","end_session_endpoint":"https://api.gitpod.io/idp/not-supported","jwks_uri":"https://api.gitpod.io/idp/keys","scopes_supported":["openid","profile","email","phone","address","offline_access"],"response_types_supported":["code","id_token","id_token token"],"grant_types_supported":["authorization_code","implicit"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"],"revocation_endpoint_auth_methods_supported":["none"],"introspection_endpoint_auth_methods_supported":["none"],"introspection_endpoint_auth_signing_alg_values_supported":["RS256"],"claims_supported":["sub","aud","exp","iat","iss","auth_time","nonce","acr","amr","c_hash","at_hash","act","scopes","client_id","azp","preferred_username","name","family_name","given_name","locale","email"],"request_uri_parameter_supported":false}` + "\n",
},
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
},
},
{
Name: "keys",
Path: "/keys",
ResponseExpectation: func(s *Service) string {
r, _ := s.keys.PublicKeys(context.Background())
return string(r)
},
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
service, err := NewService(issuerBaseURL, NewInMemoryCache())
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(service.Router())
t.Cleanup(server.Close)
resp, err := http.Get(server.URL + test.Path)
if err != nil {
t.Fatal(err)
}
respBody, err := io.ReadAll(resp.Body)
var act Expectation
act.Response = string(respBody)
if err != nil {
act.Error = err.Error()
}
if test.ResponseExpectation != nil {
test.Expectation.Response = test.ResponseExpectation(service)
}
if diff := cmp.Diff(test.Expectation, act); diff != "" {
t.Errorf("Router() mismatch (-want +got):\n%s", diff)
}
for name, expected := range test.ExpectedHeaders {
actual := resp.Header.Get(name)
if actual != expected {
t.Errorf("Unexpected value for header '%s'. got: '%s', want: '%s'", name, actual, expected)
}
}
})
}
}
func TestIDToken(t *testing.T) {
type Expectation struct {
Error string
Token *jwt.Token
}
tests := []struct {
Name string
Expectation Expectation
Org string
Audience []string
UserInfo oidc.UserInfo
}{
{
Name: "all empty",
Expectation: Expectation{
Error: "audience cannot be empty",
},
},
{
Name: "just audience",
Audience: []string{"some.audience.com"},
Expectation: Expectation{
Error: "user info cannot be nil",
},
},
{
Name: "with user info",
Audience: []string{"some.audience.com"},
UserInfo: func() oidc.UserInfo {
userInfo := oidc.NewUserInfo()
userInfo.SetName("foo")
userInfo.SetSubject("bar")
return userInfo
}(),
Expectation: Expectation{
Token: &jwt.Token{
Method: &jwt.SigningMethodRSA{Name: "RS256", Hash: crypto.SHA256},
Header: map[string]interface{}{"alg": string(jose.RS256)},
Claims: jwt.MapClaims{
"aud": []any{string("some.audience.com")},
"azp": string("some.audience.com"),
"iss": string("https://api.gitpod.io/idp"),
"name": "foo",
"sub": "bar",
},
Valid: true,
},
},
},
}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
cache := NewInMemoryCache()
service, err := NewService(issuerBaseURL, cache)
if err != nil {
t.Fatal(err)
}
var act Expectation
token, err := service.IDToken(context.TODO(), test.Org, test.Audience, test.UserInfo)
if err != nil {
act.Error = err.Error()
} else {
parsedToken, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { return &cache.current.PublicKey, nil })
if err != nil {
t.Fatalf("cannot parse IDToken result: %v", err)
}
act.Token = parsedToken
}
if diff := cmp.Diff(test.Expectation, act, cmpJWTToken()...); diff != "" {
t.Errorf("IDToken() mismatch (-want +got):\n%s", diff)
}
})
}
}
func cmpJWTToken() []cmp.Option {
return []cmp.Option{
cmpopts.IgnoreFields(jwt.Token{}, "Raw", "Signature"),
cmpopts.IgnoreMapEntries(func(k string, v any) bool {
_, ignore := map[string]struct{}{
"auth_time": {},
"c_hash": {},
"exp": {},
"iat": {},
"iss": {},
}[k]
return ignore
}),
}
}