mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-05-11 17:43:48 -04:00
add tests for oidc backchannel logout
Signed-off-by: Christian Richter <crichter@owncloud.com>
This commit is contained in:
2
go.mod
2
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
58
ocis-pkg/oidc/mocks/OIDCProvider.go
Normal file
58
ocis-pkg/oidc/mocks/OIDCProvider.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -46,7 +46,7 @@ type OIDCAuthenticator struct {
|
||||
OIDCIss string
|
||||
userInfoCache store.Store
|
||||
DefaultTokenCacheTTL time.Duration
|
||||
oidcClient oidc.OIDCProvider
|
||||
oidcClient oidc.OIDCClient
|
||||
AccessTokenVerifyMethod string
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user