diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 00000000..9d349fdf --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,32 @@ +name: test +on: + push: + branches: + - v2 + pull_request: + branches: + - v2 + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + go: ['1.22', '1.23'] + name: Linux Go ${{ matrix.go }} + steps: + - uses: actions/checkout@v4 + - name: Setup go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + # For awkward reasons we can't actually add a go.mod file for the v2 + # branch, because Go enforces that a "v2.x.x" tag has a "v2" prefix. + # + # https://github.com/coreos/go-oidc/issues/230 + - name: Setup go module + run: go mod init github.com/coreos/go-oidc + - name: Go get + run: go get ./... + - name: Test + run: go test -v ./... diff --git a/.travis.yml b/.travis.yml index 3fddaaac..9f0b0601 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,11 @@ language: go go: - - "1.12" - - "1.13" - + - "1.14" + - "1.15" +arch: + - AMD64 + - ppc64le install: - go get -v -t github.com/coreos/go-oidc/... - go get golang.org/x/tools/cmd/cover diff --git a/jwks.go b/jwks.go index e6a82c84..e262f7bd 100644 --- a/jwks.go +++ b/jwks.go @@ -10,7 +10,7 @@ import ( "time" "github.com/pquerna/cachecontrol" - jose "gopkg.in/square/go-jose.v2" + jose "gopkg.in/go-jose/go-jose.v2" ) // keysExpiryDelta is the allowed clock skew between a client and the OpenID Connect diff --git a/jwks_test.go b/jwks_test.go index 6226117a..ad19b9b5 100644 --- a/jwks_test.go +++ b/jwks_test.go @@ -14,7 +14,7 @@ import ( "testing" "time" - jose "gopkg.in/square/go-jose.v2" + jose "gopkg.in/go-jose/go-jose.v2" ) type keyServer struct { diff --git a/oidc.go b/oidc.go index b39cb515..bf35ee9e 100644 --- a/oidc.go +++ b/oidc.go @@ -13,11 +13,12 @@ import ( "io/ioutil" "mime" "net/http" + "strconv" "strings" "time" "golang.org/x/oauth2" - jose "gopkg.in/square/go-jose.v2" + jose "gopkg.in/go-jose/go-jose.v2" ) const ( @@ -192,6 +193,16 @@ type UserInfo struct { claims []byte } +type userInfoRaw struct { + Subject string `json:"sub"` + Profile string `json:"profile"` + Email string `json:"email"` + // Handle providers that return email_verified as a string + // https://forums.aws.amazon.com/thread.jspa?messageID=949441󧳁 and + // https://discuss.elastic.co/t/openid-error-after-authenticating-against-aws-cognito/206018/11 + EmailVerified stringAsBool `json:"email_verified"` +} + // Claims unmarshals the raw JSON object claims into the provided object. func (u *UserInfo) Claims(v interface{}) error { if u.claims == nil { @@ -230,12 +241,27 @@ func (p *Provider) UserInfo(ctx context.Context, tokenSource oauth2.TokenSource) return nil, fmt.Errorf("%s: %s", resp.Status, body) } - var userInfo UserInfo + ct := resp.Header.Get("Content-Type") + mediaType, _, parseErr := mime.ParseMediaType(ct) + if parseErr == nil && mediaType == "application/jwt" { + payload, err := p.remoteKeySet.VerifySignature(ctx, string(body)) + if err != nil { + return nil, fmt.Errorf("oidc: invalid userinfo jwt signature %v", err) + } + body = payload + } + + var userInfo userInfoRaw if err := json.Unmarshal(body, &userInfo); err != nil { return nil, fmt.Errorf("oidc: failed to decode userinfo: %v", err) } - userInfo.claims = body - return &userInfo, nil + return &UserInfo{ + Subject: userInfo.Subject, + Profile: userInfo.Profile, + Email: userInfo.Email, + EmailVerified: bool(userInfo.EmailVerified), + claims: body, + }, nil } // IDToken is an OpenID Connect extension that provides a predictable representation @@ -357,6 +383,28 @@ type claimSource struct { AccessToken string `json:"access_token"` } +type stringAsBool bool + +func (sb *stringAsBool) UnmarshalJSON(b []byte) error { + var result bool + err := json.Unmarshal(b, &result) + if err == nil { + *sb = stringAsBool(result) + return nil + } + var s string + err = json.Unmarshal(b, &s) + if err != nil { + return err + } + result, err = strconv.ParseBool(s) + if err != nil { + return err + } + *sb = stringAsBool(result) + return nil +} + type audience []string func (a *audience) UnmarshalJSON(b []byte) error { diff --git a/oidc_test.go b/oidc_test.go index e82d2c15..bcc49166 100644 --- a/oidc_test.go +++ b/oidc_test.go @@ -9,6 +9,8 @@ import ( "reflect" "strings" "testing" + + "golang.org/x/oauth2" ) const ( @@ -297,3 +299,184 @@ func TestNewProvider(t *testing.T) { }) } } + +type testServer struct { + contentType string + userInfo string +} + +func (ts *testServer) run(t *testing.T) string { + newMux := http.NewServeMux() + server := httptest.NewServer(newMux) + + // generated using mkjwk.org + jwks := `{ + "keys": [ + { + "kty": "RSA", + "e": "AQAB", + "use": "sig", + "kid": "test", + "alg": "RS256", + "n": "ilhCmTGFjjIPVN7Lfdn_fvpXOlzxa3eWnQGZ_eRa2ibFB1mnqoWxZJ8fkWIVFOQpsn66bIfWjBo_OI3sE6LhhRF8xhsMxlSeRKhpsWg0klYnMBeTWYET69YEAX_rGxy0MCZlFZ5tpr56EVZ-3QLfNiR4hcviqj9F2qE6jopfywsnlulJgyMi3N3kugit_JCNBJ0yz4ndZrMozVOtGqt35HhggUgYROzX6SWHUJdPXSmbAZU-SVLlesQhPfHS8LLq0sACb9OmdcwrpEFdbGCSTUPlHGkN5h6Zy8CS4s_bCdXKkjD20jv37M3GjRQkjE8vyMxFlo_qT8F8VZlSgXYTFw" + } + ] + }` + + wellKnown := fmt.Sprintf(`{ + "issuer": "%[1]s", + "authorization_endpoint": "%[1]s/auth", + "token_endpoint": "%[1]s/token", + "jwks_uri": "%[1]s/keys", + "userinfo_endpoint": "%[1]s/userinfo", + "id_token_signing_alg_values_supported": ["RS256"] + }`, server.URL) + + newMux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, req *http.Request) { + _, err := io.WriteString(w, wellKnown) + if err != nil { + w.WriteHeader(500) + } + }) + newMux.HandleFunc("/keys", func(w http.ResponseWriter, req *http.Request) { + _, err := io.WriteString(w, jwks) + if err != nil { + w.WriteHeader(500) + } + }) + newMux.HandleFunc("/userinfo", func(w http.ResponseWriter, req *http.Request) { + w.Header().Add("Content-Type", ts.contentType) + _, err := io.WriteString(w, ts.userInfo) + if err != nil { + w.WriteHeader(500) + } + }) + t.Cleanup(server.Close) + return server.URL +} + +func TestUserInfoEndpoint(t *testing.T) { + + userInfoJSON := `{ + "sub": "1234567890", + "profile": "Joe Doe", + "email": "joe@doe.com", + "email_verified": true, + "is_admin": true + }` + userInfoJSONCognitoVariant := `{ + "sub": "1234567890", + "profile": "Joe Doe", + "email": "joe@doe.com", + "email_verified": "true", + "is_admin": true + }` + + tests := []struct { + name string + server testServer + wantUserInfo UserInfo + }{ + { + name: "basic json userinfo", + server: testServer{ + contentType: "application/json", + userInfo: userInfoJSON, + }, + wantUserInfo: UserInfo{ + Subject: "1234567890", + Profile: "Joe Doe", + Email: "joe@doe.com", + EmailVerified: true, + claims: []byte(userInfoJSON), + }, + }, + { + name: "signed jwt userinfo", + server: testServer{ + contentType: "application/jwt", + // generated with jwt.io based on the private/public key pair + userInfo: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwicHJvZmlsZSI6IkpvZSBEb2UiLCJlbWFpbCI6ImpvZUBkb2UuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImlzX2FkbWluIjp0cnVlfQ.ejzc2IOLtvYp-2n5w3w4SW3rHNG9pOahnwpQCwuIaj7DvO4SxDIzeJmFPMKTJUc-1zi5T42mS4Gs2r18KWhSkk8kqYermRX0VcGEEsH0r2BG5boeza_EjCoJ5-jBPX5ODWGhu2sZIkZl29IbaVSC8jk8qKnqacchiHNmuv_xXjRsAgUsqYftrEQOxqhpfL5KN2qtgeVTczg3ABqs2-SFeEzcgA1TnA9H3AynCPCVUMFgh7xyS8jxx7DN-1vRHBySz5gNbf8z8MNx_XBLfRxxxMF24rDIE8Z2gf1DEAPr4tT38hD8ugKSE84gC3xHJWFWsRLg-Ll6OQqshs82axS00Q", + }, + wantUserInfo: UserInfo{ + Subject: "1234567890", + Profile: "Joe Doe", + Email: "joe@doe.com", + EmailVerified: true, + claims: []byte(userInfoJSON), + }, + }, + { + name: "signed jwt userinfo, content-type with charset", + server: testServer{ + contentType: "application/jwt; charset=ISO-8859-1", + // generated with jwt.io based on the private/public key pair + userInfo: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwicHJvZmlsZSI6IkpvZSBEb2UiLCJlbWFpbCI6ImpvZUBkb2UuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImlzX2FkbWluIjp0cnVlfQ.ejzc2IOLtvYp-2n5w3w4SW3rHNG9pOahnwpQCwuIaj7DvO4SxDIzeJmFPMKTJUc-1zi5T42mS4Gs2r18KWhSkk8kqYermRX0VcGEEsH0r2BG5boeza_EjCoJ5-jBPX5ODWGhu2sZIkZl29IbaVSC8jk8qKnqacchiHNmuv_xXjRsAgUsqYftrEQOxqhpfL5KN2qtgeVTczg3ABqs2-SFeEzcgA1TnA9H3AynCPCVUMFgh7xyS8jxx7DN-1vRHBySz5gNbf8z8MNx_XBLfRxxxMF24rDIE8Z2gf1DEAPr4tT38hD8ugKSE84gC3xHJWFWsRLg-Ll6OQqshs82axS00Q", + }, + wantUserInfo: UserInfo{ + Subject: "1234567890", + Profile: "Joe Doe", + Email: "joe@doe.com", + EmailVerified: true, + claims: []byte(userInfoJSON), + }, + }, + { + name: "basic json userinfo - cognito variant", + server: testServer{ + contentType: "application/json", + userInfo: userInfoJSONCognitoVariant, + }, + wantUserInfo: UserInfo{ + Subject: "1234567890", + Profile: "Joe Doe", + Email: "joe@doe.com", + EmailVerified: true, + claims: []byte(userInfoJSONCognitoVariant), + }, + }, + { + name: "signed jwt userinfo - cognito variant", + server: testServer{ + contentType: "application/jwt", + // generated with jwt.io based on the private/public key pair + userInfo: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwicHJvZmlsZSI6IkpvZSBEb2UiLCJlbWFpbCI6ImpvZUBkb2UuY29tIiwiZW1haWxfdmVyaWZpZWQiOiJ0cnVlIiwiaXNfYWRtaW4iOnRydWV9.V9j6Q208fnj7E5dhCHnAktqndvelyz6PYxmd2fLzA4ze8N770Tq9KFEE3QSM400GTxiP7tMyvBqnTj2q5Hr6DeRoy0WtLmYlnDfOJCr2qKbrPN0k94Ts9_sXAKEiJSKsTFUBHkrH4NhyWsaBaPamI8ghuqPKJ1LniNuskHUlzBmDDW4mTy15ArsaIno8S4XVn19OoqODIO30axJJxKfxEbsDR3-YW4OD9qn80Wzw0zOsGJ04NJRfO56VSprX0PhqvduOSUuHvm4cxtJIHHvj3AitrQriKZebZpXSs9PXPSPCysiQHyDz0A8y7R-sDgEhJlxe93nVbTU0itBehrbugQ", + }, + wantUserInfo: UserInfo{ + Subject: "1234567890", + Profile: "Joe Doe", + Email: "joe@doe.com", + EmailVerified: true, + claims: []byte(userInfoJSONCognitoVariant), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + serverURL := test.server.run(t) + + ctx := context.Background() + + provider, err := NewProvider(ctx, serverURL) + if err != nil { + t.Fatalf("Failed to initialize provider for test %v", err) + } + + fakeOauthToken := oauth2.Token{} + info, err := provider.UserInfo(ctx, oauth2.StaticTokenSource(&fakeOauthToken)) + if err != nil { + t.Fatalf("failed to get userinfo %v", err) + } + + if info.Email != test.wantUserInfo.Email { + t.Errorf("expected UserInfo to be %v , got %v", test.wantUserInfo, info) + } + + if info.EmailVerified != test.wantUserInfo.EmailVerified { + t.Errorf("expected UserInfo.EmailVerified to be %v , got %v", test.wantUserInfo.EmailVerified, info.EmailVerified) + } + }) + } + +} diff --git a/verify.go b/verify.go index d43f0662..3e7999c9 100644 --- a/verify.go +++ b/verify.go @@ -13,7 +13,7 @@ import ( "time" "golang.org/x/oauth2" - jose "gopkg.in/square/go-jose.v2" + jose "gopkg.in/go-jose/go-jose.v2" ) const ( @@ -185,7 +185,7 @@ func parseClaim(raw []byte, name string, v interface{}) error { return json.Unmarshal([]byte(val), v) } -// Verify parses a raw ID Token, verifies it's been signed by the provider, preforms +// Verify parses a raw ID Token, verifies it's been signed by the provider, performs // any additional checks depending on the Config, and returns the payload. // // Verify does NOT do nonce validation, which is the callers responsibility. diff --git a/verify_test.go b/verify_test.go index d2fffa9c..de100986 100644 --- a/verify_test.go +++ b/verify_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - jose "gopkg.in/square/go-jose.v2" + jose "gopkg.in/go-jose/go-jose.v2" ) type testVerifier struct {