add tests for oidc backchannel logout

Signed-off-by: Christian Richter <crichter@owncloud.com>
This commit is contained in:
Christian Richter
2023-04-13 17:29:57 +02:00
parent 15691ae78a
commit e88a0d7bc3
10 changed files with 143 additions and 162 deletions

2
go.mod
View File

@@ -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

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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"`
}

View 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
}

View File

@@ -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
}
}

View File

@@ -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.

View File

@@ -46,7 +46,7 @@ type OIDCAuthenticator struct {
OIDCIss string
userInfoCache store.Store
DefaultTokenCacheTTL time.Duration
oidcClient oidc.OIDCProvider
oidcClient oidc.OIDCClient
AccessTokenVerifyMethod string
}

View File

@@ -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
}