diff --git a/go.mod b/go.mod index 8ec3cd031..5ab0d7b96 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/armon/go-radix v1.0.0 github.com/bbalet/stopwords v1.0.0 github.com/blevesearch/bleve/v2 v2.3.7 + github.com/coreos/go-oidc v2.2.1+incompatible github.com/coreos/go-oidc/v3 v3.4.0 github.com/cs3org/go-cs3apis v0.0.0-20221012090518-ef2996678965 github.com/cs3org/reva/v2 v2.12.1-0.20230417084429-b3d96f9db80c @@ -154,7 +155,6 @@ require ( github.com/cilium/ebpf v0.7.0 // indirect github.com/cloudflare/circl v1.2.0 // indirect github.com/containerd/cgroups v1.0.4 // indirect - github.com/coreos/go-oidc v2.2.1+incompatible // indirect github.com/coreos/go-semver v0.3.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect diff --git a/ocis-pkg/oidc/client.go b/ocis-pkg/oidc/client.go index bee227fdd..83ec35210 100644 --- a/ocis-pkg/oidc/client.go +++ b/ocis-pkg/oidc/client.go @@ -3,6 +3,7 @@ package oidc import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -22,8 +23,8 @@ import ( "gopkg.in/square/go-jose.v2" ) -// OIDCProvider used to mock the oidc provider during tests -type OIDCProvider interface { +// OIDCClient used to mock the oidc client during tests +type OIDCClient interface { UserInfo(ctx context.Context, ts oauth2.TokenSource) (*UserInfo, error) VerifyAccessToken(ctx context.Context, token string) (jwt.RegisteredClaims, []string, error) VerifyLogoutToken(ctx context.Context, token string) (*LogoutToken, error) @@ -55,7 +56,7 @@ type oidcClient struct { providerLock *sync.Mutex skipIssuerValidation bool accessTokenVerifyMethod string - remoteKeySet KeySet // TODO replace usage with keyfunc? + remoteKeySet KeySet algorithms []string JWKSOptions config.JWKS @@ -81,7 +82,7 @@ var supportedAlgorithms = map[string]bool{ } // NewOIDCClient returns an OIDC client for the given issuer -func NewOIDCClient(opts ...Option) OIDCProvider { +func NewOIDCClient(opts ...Option) OIDCClient { options := newOptions(opts...) return &oidcClient{ @@ -94,6 +95,8 @@ func NewOIDCClient(opts ...Option) OIDCProvider { jwksLock: &sync.Mutex{}, clientID: options.ClientID, skipClientIDCheck: options.SkipClientIDCheck, + remoteKeySet: options.KeySet, + provider: options.ProviderMetadata, } } @@ -414,3 +417,24 @@ func unmarshalResp(r *http.Response, body []byte, v interface{}) error { } return fmt.Errorf("expected Content-Type = application/json, got %q: %v", ct, err) } + +func contains(sli []string, ele string) bool { + for _, s := range sli { + if s == ele { + return true + } + } + return false +} + +func parseJWT(p string) ([]byte, error) { + parts := strings.Split(p, ".") + if len(parts) < 2 { + return nil, fmt.Errorf("oidc: malformed jwt, expected 3 parts got %d", len(parts)) + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("oidc: malformed jwt payload: %v", err) + } + return payload, nil +} diff --git a/ocis-pkg/oidc/logoutverify_test.go b/ocis-pkg/oidc/client_test.go similarity index 92% rename from ocis-pkg/oidc/logoutverify_test.go rename to ocis-pkg/oidc/client_test.go index 8ef15293a..25d77dc4b 100644 --- a/ocis-pkg/oidc/logoutverify_test.go +++ b/ocis-pkg/oidc/client_test.go @@ -1,4 +1,4 @@ -package oidc +package oidc_test import ( "context" @@ -10,6 +10,7 @@ import ( "testing" gOidc "github.com/coreos/go-oidc/v3/oidc" + "github.com/owncloud/ocis/v2/ocis-pkg/oidc" "gopkg.in/square/go-jose.v2" ) @@ -248,25 +249,36 @@ func (t *testVerifier) VerifySignature(ctx context.Context, jwt string) ([]byte, return jws.Verify(&t.jwk) } -func (v logoutVerificationTest) runGetToken(t *testing.T) (*LogoutToken, error) { +func (v logoutVerificationTest) runGetToken(t *testing.T) (*oidc.LogoutToken, error) { token := v.signKey.sign(t, []byte(v.logoutToken)) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - issuer := "https://foo" - if v.issuer != "" { - issuer = v.issuer - } var ks gOidc.KeySet if v.verificationKey == nil { ks = &testVerifier{v.signKey.jwk()} } else { ks = &testVerifier{v.verificationKey.jwk()} } - verifier := NewLogoutVerifier(issuer, ks, &v.config) - return verifier.Verify(ctx, token) + pm := oidc.ProviderMetadata{} + var clientID string + switch t.Name() { + case "TestLogoutVerify/good_token": + clientID = "s6BhdRkqt3" + default: + clientID = "client1" + } + verifier := oidc.NewOIDCClient( + oidc.WithOidcIssuer(issuer), + oidc.WithKeySet(ks), + oidc.WithConfig(&v.config), + oidc.WithProviderMetadata(&pm), + oidc.WithClientID(clientID), + ) + + return verifier.VerifyLogoutToken(ctx, token) } func (l logoutVerificationTest) run(t *testing.T) { diff --git a/ocis-pkg/oidc/logoutverify.go b/ocis-pkg/oidc/logoutverify.go deleted file mode 100644 index 67041e8c2..000000000 --- a/ocis-pkg/oidc/logoutverify.go +++ /dev/null @@ -1,143 +0,0 @@ -package oidc - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "strings" - - "gopkg.in/square/go-jose.v2" - - gOidc "github.com/coreos/go-oidc/v3/oidc" -) - -// This adds the ability to verify Logout Tokens as specified in https://openid.net/specs/openid-connect-backchannel-1_0.html - -// LogoutTokenVerifier provides verification for Logout Tokens. -type LogoutTokenVerifier struct { - keySet gOidc.KeySet - config *gOidc.Config - issuer string -} - -func NewLogoutVerifier(issuerURL string, keySet gOidc.KeySet, config *gOidc.Config) *LogoutTokenVerifier { - return &LogoutTokenVerifier{keySet: keySet, config: config, issuer: issuerURL} -} - -//Upon receiving a logout request at the back-channel logout URI, the RP MUST validate the Logout Token as follows: -// -//1. If the Logout Token is encrypted, decrypt it using the keys and algorithms that the Client specified during Registration that the OP was to use to encrypt ID Tokens. If ID Token encryption was negotiated with the OP at Registration time and the Logout Token is not encrypted, the RP SHOULD reject it. -//2. Validate the Logout Token signature in the same way that an ID Token signature is validated, with the following refinements. -//3. Validate the iss, aud, and iat Claims in the same way they are validated in ID Tokens. -//4. Verify that the Logout Token contains a sub Claim, a sid Claim, or both. -//5. Verify that the Logout Token contains an events Claim whose value is JSON object containing the member name http://schemas.openid.net/event/backchannel-logout. -//6. Verify that the Logout Token does not contain a nonce Claim. -//7. Optionally verify that another Logout Token with the same jti value has not been recently received. -//If any of the validation steps fails, reject the Logout Token and return an HTTP 400 Bad Request error. Otherwise, proceed to perform the logout actions. - -// Verify verifies a Logout token according to Specs -func (v *LogoutTokenVerifier) Verify(ctx context.Context, rawIDToken string) (*LogoutToken, error) { - jws, err := jose.ParseSigned(rawIDToken) - if err != nil { - return nil, err - } - // Throw out tokens with invalid claims before trying to verify the token. This lets - // us do cheap checks before possibly re-syncing keys. - payload, err := parseJWT(rawIDToken) - if err != nil { - return nil, fmt.Errorf("oidc: malformed jwt: %v", err) - } - var token LogoutToken - if err := json.Unmarshal(payload, &token); err != nil { - return nil, fmt.Errorf("oidc: failed to unmarshal claims: %v", err) - } - - //4. Verify that the Logout Token contains a sub Claim, a sid Claim, or both. - if token.Subject == "" && token.SessionId == "" { - return nil, fmt.Errorf("oidc: logout token must contain either sub or sid and MAY contain both") - } - //5. Verify that the Logout Token contains an events Claim whose value is JSON object containing the member name http://schemas.openid.net/event/backchannel-logout. - if token.Events.Event == nil { - return nil, fmt.Errorf("oidc: logout token must contain logout event") - } - //6. Verify that the Logout Token does not contain a nonce Claim. - type nonce struct { - Nonce *string `json:"nonce"` - } - var n nonce - json.Unmarshal(payload, &n) - if n.Nonce != nil { - return nil, fmt.Errorf("oidc: nonce on logout token MUST NOT be present") - } - // Check issuer. - if !v.config.SkipIssuerCheck && token.Issuer != v.issuer { - return nil, fmt.Errorf("oidc: id token issued by a different provider, expected %q got %q", v.issuer, token.Issuer) - } - - // If a client ID has been provided, make sure it's part of the audience. SkipClientIDCheck must be true if ClientID is empty. - // - // This check DOES NOT ensure that the ClientID is the party to which the ID Token was issued (i.e. Authorized party). - if !v.config.SkipClientIDCheck { - if v.config.ClientID != "" { - if !contains(token.Audience, v.config.ClientID) { - return nil, fmt.Errorf("oidc: expected audience %q got %q", v.config.ClientID, token.Audience) - } - } else { - return nil, fmt.Errorf("oidc: invalid configuration, clientID must be provided or SkipClientIDCheck must be set") - } - } - - switch len(jws.Signatures) { - case 0: - return nil, fmt.Errorf("oidc: id token not signed") - case 1: - default: - return nil, fmt.Errorf("oidc: multiple signatures on id token not supported") - } - - sig := jws.Signatures[0] - supportedSigAlgs := v.config.SupportedSigningAlgs - if len(supportedSigAlgs) == 0 { - supportedSigAlgs = []string{gOidc.RS256} - } - - if !contains(supportedSigAlgs, sig.Header.Algorithm) { - return nil, fmt.Errorf("oidc: id token signed with unsupported algorithm, expected %q got %q", supportedSigAlgs, sig.Header.Algorithm) - } - - gotPayload, err := v.keySet.VerifySignature(ctx, rawIDToken) - if err != nil { - return nil, fmt.Errorf("failed to verify signature: %v", err) - } - - // Ensure that the payload returned by the square actually matches the payload parsed earlier. - if !bytes.Equal(gotPayload, payload) { - return nil, errors.New("oidc: internal error, payload parsed did not match previous payload") - } - - return &token, nil -} - -func parseJWT(p string) ([]byte, error) { - parts := strings.Split(p, ".") - if len(parts) < 2 { - return nil, fmt.Errorf("oidc: malformed jwt, expected 3 parts got %d", len(parts)) - } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil, fmt.Errorf("oidc: malformed jwt payload: %v", err) - } - return payload, nil -} - -func contains(sli []string, ele string) bool { - for _, s := range sli { - if s == ele { - return true - } - } - return false -} diff --git a/ocis-pkg/oidc/metadata.go b/ocis-pkg/oidc/metadata.go index c67b03b1b..52d1b918c 100644 --- a/ocis-pkg/oidc/metadata.go +++ b/ocis-pkg/oidc/metadata.go @@ -81,13 +81,13 @@ type LogoutToken struct { // The Session Id SessionId string `json:"sid"` - Events logoutEvent `json:"events"` + Events LogoutEvent `json:"events"` // Jwt Id JwtID string `json:"jti"` } -type logoutEvent struct { +type LogoutEvent struct { Event *struct{} `json:"http://schemas.openid.net/event/backchannel-logout"` } diff --git a/ocis-pkg/oidc/mocks/OIDCProvider.go b/ocis-pkg/oidc/mocks/OIDCProvider.go new file mode 100644 index 000000000..a0396d819 --- /dev/null +++ b/ocis-pkg/oidc/mocks/OIDCProvider.go @@ -0,0 +1,58 @@ +// Code generated by mockery v0.0.0-dev. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + oauth2 "golang.org/x/oauth2" + + oidc "github.com/coreos/go-oidc" +) + +// OIDCProvider is an autogenerated mock type for the OIDCProvider type +type OIDCProvider struct { + mock.Mock +} + +// UserInfo provides a mock function with given fields: ctx, ts +func (_m *OIDCProvider) UserInfo(ctx context.Context, ts oauth2.TokenSource) (*oidc.UserInfo, error) { + ret := _m.Called(ctx, ts) + + var r0 *oidc.UserInfo + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, oauth2.TokenSource) (*oidc.UserInfo, error)); ok { + return rf(ctx, ts) + } + if rf, ok := ret.Get(0).(func(context.Context, oauth2.TokenSource) *oidc.UserInfo); ok { + r0 = rf(ctx, ts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*oidc.UserInfo) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, oauth2.TokenSource) error); ok { + r1 = rf(ctx, ts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type mockConstructorTestingTNewOIDCProvider interface { + mock.TestingT + Cleanup(func()) +} + +// NewOIDCProvider creates a new instance of OIDCProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewOIDCProvider(t mockConstructorTestingTNewOIDCProvider) *OIDCProvider { + mock := &OIDCProvider{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/ocis-pkg/oidc/options.go b/ocis-pkg/oidc/options.go index 2cc5716b0..5e0aafd7f 100644 --- a/ocis-pkg/oidc/options.go +++ b/ocis-pkg/oidc/options.go @@ -5,6 +5,8 @@ import ( "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/proxy/pkg/config" + + gOidc "github.com/coreos/go-oidc/v3/oidc" ) // Option defines a single option function. @@ -20,6 +22,8 @@ type Options struct { OidcIssuer string // JWKSOptions to use when retrieving keys JWKSOptions config.JWKS + // KeySet to use when verifiing signatures + KeySet KeySet // AccessTokenVerifyMethod to use when verifying access tokens // TODO pass a function or interface to verify? an AccessTokenVerifier? AccessTokenVerifyMethod string @@ -28,6 +32,11 @@ type Options struct { ClientID string // SkipClientIDCheck must be true if ClientID is empty SkipClientIDCheck bool + // Config to use + Config *gOidc.Config + + // ProviderMetadata to use + ProviderMetadata *ProviderMetadata } // newOptions initializes the available default options. @@ -76,6 +85,13 @@ func WithJWKSOptions(val config.JWKS) Option { } } +// WithKeySet provides a function to set the KeySet option. +func WithKeySet(val KeySet) Option { + return func(o *Options) { + o.KeySet = val + } +} + // WithClientID provides a function to set the clientID option. func WithClientID(val string) Option { return func(o *Options) { @@ -89,3 +105,17 @@ func WithSkipClientIDCheck(val bool) Option { o.SkipClientIDCheck = val } } + +// WithSkipClientIDCheck provides a function to set the skipClientIDCheck option. +func WithConfig(val *gOidc.Config) Option { + return func(o *Options) { + o.Config = val + } +} + +// WithProviderMetadata provides a function to set the provider option. +func WithProviderMetadata(val *ProviderMetadata) Option { + return func(o *Options) { + o.ProviderMetadata = val + } +} diff --git a/services/proxy/pkg/command/server.go b/services/proxy/pkg/command/server.go index c91387676..e336c2d88 100644 --- a/services/proxy/pkg/command/server.go +++ b/services/proxy/pkg/command/server.go @@ -47,7 +47,7 @@ type StaticRouteHandler struct { userInfoCache microstore.Store logger log.Logger config config.Config - oidcClient oidc.OIDCProvider + oidcClient oidc.OIDCClient } // Server is the entrypoint for the server command. diff --git a/services/proxy/pkg/middleware/oidc_auth.go b/services/proxy/pkg/middleware/oidc_auth.go index 9826c99b6..4e46dc70c 100644 --- a/services/proxy/pkg/middleware/oidc_auth.go +++ b/services/proxy/pkg/middleware/oidc_auth.go @@ -46,7 +46,7 @@ type OIDCAuthenticator struct { OIDCIss string userInfoCache store.Store DefaultTokenCacheTTL time.Duration - oidcClient oidc.OIDCProvider + oidcClient oidc.OIDCClient AccessTokenVerifyMethod string } diff --git a/services/proxy/pkg/middleware/options.go b/services/proxy/pkg/middleware/options.go index 901b84e16..d4759079f 100644 --- a/services/proxy/pkg/middleware/options.go +++ b/services/proxy/pkg/middleware/options.go @@ -35,7 +35,7 @@ type Options struct { // SettingsRoleService for the roles API in settings SettingsRoleService settingssvc.RoleService // OIDCProviderFunc to lazily initialize an oidc provider, must be set for the oidc_auth middleware - OIDCClient oidc.OIDCProvider + OIDCClient oidc.OIDCClient // OIDCIss is the oidcAuth-issuer OIDCIss string // RevaGatewayClient to send requests to the reva gateway @@ -115,7 +115,7 @@ func SettingsRoleService(rc settingssvc.RoleService) Option { } // OIDCClient provides a function to set the the oidc client option. -func OIDCClient(val oidc.OIDCProvider) Option { +func OIDCClient(val oidc.OIDCClient) Option { return func(o *Options) { o.OIDCClient = val }