Merge pull request #6007 from dragonchaser/backchannellogout

[full-ci] Implement backchannel logout
This commit is contained in:
Christian Richter
2023-04-20 13:23:23 +02:00
committed by GitHub
15 changed files with 1156 additions and 171 deletions
+2 -2
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
@@ -100,6 +101,7 @@ require (
google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f
google.golang.org/grpc v1.54.0
google.golang.org/protobuf v1.28.1
gopkg.in/square/go-jose.v2 v2.6.0
gopkg.in/yaml.v2 v2.4.0
gotest.tools/v3 v3.4.0
stash.kopano.io/kgol/oidc-go v0.3.4
@@ -153,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
@@ -313,7 +314,6 @@ require (
golang.org/x/tools v0.7.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+2
View File
@@ -629,6 +629,8 @@ github.com/crewjam/saml v0.4.13 h1:TYHggH/hwP7eArqiXSJUvtOPNzQDyQ7vwmwEqlFWhMc=
github.com/crewjam/saml v0.4.13/go.mod h1:igEejV+fihTIlHXYP8zOec3V5A8y3lws5bQBFsTm4gA=
github.com/cs3org/reva/v2 v2.12.1-0.20230417084429-b3d96f9db80c h1:H6OjKTaRowZfAU/Hwvv4W0pLFFH/KNbHaNVNw3ANoHU=
github.com/cs3org/reva/v2 v2.12.1-0.20230417084429-b3d96f9db80c/go.mod h1:FNAYs5H3xs8v0OFmNgZtiMAzIMXd/6TJmO0uZuNn8pQ=
github.com/cs3org/reva/v2 v2.12.1-0.20230331184913-eca8953fb6e9 h1:zUMD0UvKVPbR3UaodnA0GXuBycxrIuaO8Kshgi3iKKI=
github.com/cs3org/reva/v2 v2.12.1-0.20230331184913-eca8953fb6e9/go.mod h1:FNAYs5H3xs8v0OFmNgZtiMAzIMXd/6TJmO0uZuNn8pQ=
github.com/cubewise-code/go-mime v0.0.0-20200519001935-8c5762b177d8 h1:Z9lwXumT5ACSmJ7WGnFl+OMLLjpz5uR2fyz7dC255FI=
github.com/cubewise-code/go-mime v0.0.0-20200519001935-8c5762b177d8/go.mod h1:4abs/jPXcmJzYoYGF91JF9Uq9s/KL5n1jvFDix8KcqY=
github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4=
+3 -3
View File
@@ -6,7 +6,7 @@ import (
"strings"
"sync"
gOidc "github.com/coreos/go-oidc/v3/oidc"
goidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
"golang.org/x/oauth2"
)
@@ -24,7 +24,7 @@ func newOidcOptions(opts ...Option) Options {
// OIDCProvider used to mock the oidc provider during tests
type OIDCProvider interface {
UserInfo(ctx context.Context, ts oauth2.TokenSource) (*gOidc.UserInfo, error)
UserInfo(ctx context.Context, ts oauth2.TokenSource) (*goidc.UserInfo, error)
}
// OidcAuth provides a middleware to authenticate a bearer auth with an OpenID Connect identity provider
@@ -38,7 +38,7 @@ func OidcAuth(opts ...Option) func(http.Handler) http.Handler {
// Initialize a provider by specifying the issuer URL.
// it will fetch the keys from the issuer using the .well-known
// endpoint
return gOidc.NewProvider(
return goidc.NewProvider(
context.WithValue(context.Background(), oauth2.HTTPClient, http.Client{}),
opt.OidcIssuer,
)
+438
View File
@@ -0,0 +1,438 @@
package oidc
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/MicahParks/keyfunc"
goidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v4"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
"golang.org/x/oauth2"
"gopkg.in/square/go-jose.v2"
)
// 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)
}
// KeySet is a set of public JSON Web Keys that can be used to validate the signature
// of JSON web tokens. This is expected to be backed by a remote key set through
// provider metadata discovery or an in-memory set of keys delivered out-of-band.
type KeySet interface {
// VerifySignature parses the JSON web token, verifies the signature, and returns
// the raw payload. Header and claim fields are validated by other parts of the
// package. For example, the KeySet does not need to check values such as signature
// algorithm, issuer, and audience since the IDTokenVerifier validates these values
// independently.
//
// If VerifySignature makes HTTP requests to verify the token, it's expected to
// use any HTTP client associated with the context through ClientContext.
VerifySignature(ctx context.Context, jwt string) (payload []byte, err error)
}
type oidcClient struct {
// Logger to use for logging, must be set
Logger log.Logger
clientID string
skipClientIDCheck bool
issuer string
provider *ProviderMetadata
providerLock *sync.Mutex
skipIssuerValidation bool
accessTokenVerifyMethod string
remoteKeySet KeySet
algorithms []string
JWKSOptions config.JWKS
JWKS *keyfunc.JWKS
jwksLock *sync.Mutex
httpClient *http.Client
}
// _supportedAlgorithms is a list of algorithms explicitly supported by this
// package. If a provider supports other algorithms, such as HS256 or none,
// those values won't be passed to the IDTokenVerifier.
var _supportedAlgorithms = map[string]bool{
RS256: true,
RS384: true,
RS512: true,
ES256: true,
ES384: true,
ES512: true,
PS256: true,
PS384: true,
PS512: true,
}
// NewOIDCClient returns an OIDClient instance for the given issuer
func NewOIDCClient(opts ...Option) OIDCClient {
options := newOptions(opts...)
return &oidcClient{
Logger: options.Logger,
issuer: options.OIDCIssuer,
httpClient: options.HTTPClient,
accessTokenVerifyMethod: options.AccessTokenVerifyMethod,
JWKSOptions: options.JWKSOptions, // TODO I don't like that we pass down config options ...
providerLock: &sync.Mutex{},
jwksLock: &sync.Mutex{},
clientID: options.ClientID,
skipClientIDCheck: options.SkipClientIDCheck,
remoteKeySet: options.KeySet,
provider: options.ProviderMetadata,
}
}
func (c *oidcClient) lookupWellKnownOpenidConfiguration(ctx context.Context) error {
c.providerLock.Lock()
defer c.providerLock.Unlock()
if c.provider == nil {
wellKnown := strings.TrimSuffix(c.issuer, "/") + wellknownPath
req, err := http.NewRequest("GET", wellKnown, nil)
if err != nil {
return err
}
resp, err := c.httpClient.Do(req.WithContext(ctx))
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("unable to read response body: %v", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: %s", resp.Status, body)
}
var p ProviderMetadata
err = unmarshalResp(resp, body, &p)
if err != nil {
return fmt.Errorf("oidc: failed to decode provider discovery object: %v", err)
}
if !c.skipIssuerValidation && p.Issuer != c.issuer {
return fmt.Errorf("oidc: issuer did not match the issuer returned by provider, expected %q got %q", c.issuer, p.Issuer)
}
var algs []string
for _, a := range p.IDTokenSigningAlgValuesSupported {
if _supportedAlgorithms[a] {
algs = append(algs, a)
}
}
c.provider = &p
c.algorithms = algs
c.remoteKeySet = goidc.NewRemoteKeySet(goidc.ClientContext(ctx, c.httpClient), p.JwksURI)
}
return nil
}
func (c *oidcClient) getKeyfunc() *keyfunc.JWKS {
c.jwksLock.Lock()
defer c.jwksLock.Unlock()
if c.JWKS == nil {
var err error
c.Logger.Debug().Str("jwks", c.provider.JwksURI).Msg("discovered jwks endpoint")
options := keyfunc.Options{
Client: c.httpClient,
RefreshErrorHandler: func(err error) {
c.Logger.Error().Err(err).Msg("There was an error with the jwt.Keyfunc")
},
RefreshInterval: time.Minute * time.Duration(c.JWKSOptions.RefreshInterval),
RefreshRateLimit: time.Second * time.Duration(c.JWKSOptions.RefreshRateLimit),
RefreshTimeout: time.Second * time.Duration(c.JWKSOptions.RefreshTimeout),
RefreshUnknownKID: c.JWKSOptions.RefreshUnknownKID,
}
c.JWKS, err = keyfunc.Get(c.provider.JwksURI, options)
if err != nil {
c.JWKS = nil
c.Logger.Error().Err(err).Msg("Failed to create JWKS from resource at the given URL.")
return nil
}
}
return c.JWKS
}
type stringAsBool bool
// Claims unmarshals the raw JSON string into a bool.
func (sb *stringAsBool) UnmarshalJSON(b []byte) error {
v, err := strconv.ParseBool(string(b))
if err != nil {
return err
}
*sb = stringAsBool(v)
return nil
}
// UserInfo represents the OpenID Connect userinfo claims.
type UserInfo struct {
Subject string `json:"sub"`
Profile string `json:"profile"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
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&#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 {
return errors.New("oidc: claims not set")
}
return json.Unmarshal(u.claims, v)
}
// UserInfo retrieves the userinfo from a Token
func (c *oidcClient) UserInfo(ctx context.Context, tokenSource oauth2.TokenSource) (*UserInfo, error) {
if err := c.lookupWellKnownOpenidConfiguration(ctx); err != nil {
return nil, err
}
if c.provider.UserinfoEndpoint == "" {
return nil, errors.New("oidc: user info endpoint is not supported by this provider")
}
req, err := http.NewRequest("GET", c.provider.UserinfoEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("oidc: create GET request: %v", err)
}
token, err := tokenSource.Token()
if err != nil {
return nil, fmt.Errorf("oidc: get access token: %v", err)
}
token.SetAuthHeader(req)
resp, err := c.httpClient.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: %s", resp.Status, body)
}
ct := resp.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(ct)
if err == nil && mediaType == "application/jwt" {
payload, err := c.remoteKeySet.VerifySignature(goidc.ClientContext(ctx, c.httpClient), 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)
}
return &UserInfo{
Subject: userInfo.Subject,
Profile: userInfo.Profile,
Email: userInfo.Email,
EmailVerified: bool(userInfo.EmailVerified),
claims: body,
}, nil
}
func (c *oidcClient) VerifyAccessToken(ctx context.Context, token string) (jwt.RegisteredClaims, []string, error) {
var mapClaims []string
if err := c.lookupWellKnownOpenidConfiguration(ctx); err != nil {
return jwt.RegisteredClaims{}, mapClaims, err
}
switch c.accessTokenVerifyMethod {
case config.AccessTokenVerificationJWT:
return c.verifyAccessTokenJWT(token)
case config.AccessTokenVerificationNone:
c.Logger.Debug().Msg("Access Token verification disabled")
return jwt.RegisteredClaims{}, mapClaims, nil
default:
c.Logger.Error().Str("access_token_verify_method", c.accessTokenVerifyMethod).Msg("Unknown Access Token verification setting")
return jwt.RegisteredClaims{}, mapClaims, errors.New("unknown Access Token Verification method")
}
}
// verifyAccessTokenJWT tries to parse and verify the access token as a JWT.
func (c *oidcClient) verifyAccessTokenJWT(token string) (jwt.RegisteredClaims, []string, error) {
var claims jwt.RegisteredClaims
var mapClaims []string
jwks := c.getKeyfunc()
if jwks == nil {
return claims, mapClaims, errors.New("error initializing jwks keyfunc")
}
_, err := jwt.ParseWithClaims(token, &claims, jwks.Keyfunc)
if err != nil {
return claims, mapClaims, err
}
_, mapClaims, err = new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{})
// TODO: decode mapClaims to sth readable
c.Logger.Debug().Interface("access token", &claims).Msg("parsed access token")
if err != nil {
c.Logger.Info().Err(err).Msg("Failed to parse/verify the access token.")
return claims, mapClaims, err
}
if !claims.VerifyIssuer(c.issuer, true) {
vErr := jwt.ValidationError{}
vErr.Inner = jwt.ErrTokenInvalidIssuer
vErr.Errors |= jwt.ValidationErrorIssuer
return claims, mapClaims, vErr
}
return claims, mapClaims, nil
}
func (c *oidcClient) VerifyLogoutToken(ctx context.Context, rawToken string) (*LogoutToken, error) {
if err := c.lookupWellKnownOpenidConfiguration(ctx); err != nil {
return nil, err
}
jws, err := jose.ParseSigned(rawToken)
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(rawToken)
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.
var n struct {
Nonce *string `json:"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 !c.skipIssuerValidation && token.Issuer != c.issuer {
return nil, fmt.Errorf("oidc: logout token issued by a different provider, expected %q got %q", c.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 !c.skipClientIDCheck {
if c.clientID != "" {
if !contains(token.Audience, c.clientID) {
return nil, fmt.Errorf("oidc: expected audience %q got %q", c.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: logout token not signed")
case 1:
// do nothing
default:
return nil, fmt.Errorf("oidc: multiple signatures on logout token not supported")
}
sig := jws.Signatures[0]
supportedSigAlgs := c.algorithms
if len(supportedSigAlgs) == 0 {
supportedSigAlgs = []string{RS256}
}
if !contains(supportedSigAlgs, sig.Header.Algorithm) {
return nil, fmt.Errorf("oidc: logout token signed with unsupported algorithm, expected %q got %q", supportedSigAlgs, sig.Header.Algorithm)
}
gotPayload, err := c.remoteKeySet.VerifySignature(goidc.ClientContext(ctx, c.httpClient), rawToken)
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 unmarshalResp(r *http.Response, body []byte, v interface{}) error {
err := json.Unmarshal(body, &v)
if err == nil {
return nil
}
ct := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(ct)
if err == nil && mediaType == "application/json" {
return fmt.Errorf("got Content-Type = application/json, but could not unmarshal as JSON: %v", err)
}
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
}
+308
View File
@@ -0,0 +1,308 @@
package oidc_test
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"fmt"
"testing"
goidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
"gopkg.in/square/go-jose.v2"
)
type signingKey struct {
keyID string // optional
priv interface{}
pub interface{}
alg jose.SignatureAlgorithm
}
// sign creates a JWS using the private key from the provided payload.
func (s *signingKey) sign(t testing.TB, payload []byte) string {
privKey := &jose.JSONWebKey{Key: s.priv, Algorithm: string(s.alg), KeyID: s.keyID}
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: s.alg, Key: privKey}, nil)
if err != nil {
t.Fatal(err)
}
jws, err := signer.Sign(payload)
if err != nil {
t.Fatal(err)
}
data, err := jws.CompactSerialize()
if err != nil {
t.Fatal(err)
}
return data
}
func (s *signingKey) jwk() jose.JSONWebKey {
return jose.JSONWebKey{Key: s.pub, Use: "sig", Algorithm: string(s.alg), KeyID: s.keyID}
}
func TestLogoutVerify(t *testing.T) {
tests := []logoutVerificationTest{
{
name: "good token",
logoutToken: ` {
"iss": "https://foo",
"sub": "248289761001",
"aud": "s6BhdRkqt3",
"iat": 1471566154,
"jti": "bWJq",
"sid": "08a5019c-17e1-4977-8f42-65a12843ea02",
"events": {
"http://schemas.openid.net/event/backchannel-logout": {}
}
}`,
config: goidc.Config{
SkipClientIDCheck: true,
},
signKey: newRSAKey(t),
},
{
name: "invalid issuer",
issuer: "https://bar",
logoutToken: `{"iss":"https://foo"}`,
config: goidc.Config{
SkipClientIDCheck: true,
SkipExpiryCheck: true,
},
signKey: newRSAKey(t),
wantErr: true,
},
{
name: "invalid sig",
logoutToken: `{
"iss": "https://foo",
"sub": "248289761001",
"aud": "s6BhdRkqt3",
"iat": 1471566154,
"jti": "bWJq",
"sid": "08a5019c-17e1-4977-8f42-65a12843ea02",
"events": {
"http://schemas.openid.net/event/backchannel-logout": {}
}
}`,
config: goidc.Config{
SkipClientIDCheck: true,
SkipExpiryCheck: true,
},
signKey: newRSAKey(t),
verificationKey: newRSAKey(t),
wantErr: true,
},
{
name: "no sid and no sub",
logoutToken: ` {
"iss": "https://foo",
"aud": "s6BhdRkqt3",
"iat": 1471566154,
"jti": "bWJq",
"events": {
"http://schemas.openid.net/event/backchannel-logout": {}
}
}`,
config: goidc.Config{
SkipClientIDCheck: true,
},
signKey: newRSAKey(t),
wantErr: true,
},
{
name: "Prohibited nonce present",
logoutToken: ` {
"iss": "https://foo",
"sub": "248289761001",
"aud": "s6BhdRkqt3",
"iat": 1471566154,
"jti": "bWJq",
"nonce" : "prohibited",
"events": {
"http://schemas.openid.net/event/backchannel-logout": {}
}
}`,
config: goidc.Config{
SkipClientIDCheck: true,
},
signKey: newRSAKey(t),
wantErr: true,
},
{
name: "Wrong Event string",
logoutToken: ` {
"iss": "https://foo",
"sub": "248289761001",
"aud": "s6BhdRkqt3",
"iat": 1471566154,
"jti": "bWJq",
"sid": "08a5019c-17e1-4977-8f42-65a12843ea02",
"events": {
"not a logout event": {}
}
}`,
config: goidc.Config{
SkipClientIDCheck: true,
},
signKey: newRSAKey(t),
wantErr: true,
},
{
name: "No Event string",
logoutToken: ` {
"iss": "https://foo",
"sub": "248289761001",
"aud": "s6BhdRkqt3",
"iat": 1471566154,
"jti": "bWJq",
"sid": "08a5019c-17e1-4977-8f42-65a12843ea02",
}`,
config: goidc.Config{
SkipClientIDCheck: true,
},
signKey: newRSAKey(t),
wantErr: true,
},
}
for _, test := range tests {
t.Run(test.name, test.run)
}
}
func TestVerifyAudienceLogout(t *testing.T) {
tests := []logoutVerificationTest{
{
name: "good audience",
logoutToken: `{"iss":"https://foo","aud":"client1","sub":"subject","events": {
"http://schemas.openid.net/event/backchannel-logout": {}
}
}`,
config: goidc.Config{
ClientID: "client1",
SkipExpiryCheck: true,
},
signKey: newRSAKey(t),
},
{
name: "mismatched audience",
logoutToken: `{"iss":"https://foo","aud":"client2","sub":"subject","events": {
"http://schemas.openid.net/event/backchannel-logout": {}
}}`,
config: goidc.Config{
ClientID: "client1",
SkipExpiryCheck: true,
},
signKey: newRSAKey(t),
wantErr: true,
},
{
name: "multiple audiences, one matches",
logoutToken: `{"iss":"https://foo","aud":["client1","client2"],"sub":"subject","events": {
"http://schemas.openid.net/event/backchannel-logout": {}
}}`,
config: goidc.Config{
ClientID: "client2",
SkipExpiryCheck: true,
},
signKey: newRSAKey(t),
},
}
for _, test := range tests {
t.Run(test.name, test.run)
}
}
type logoutVerificationTest struct {
// Name of the subtest.
name string
// If not provided defaults to "https://foo"
issuer string
// JWT payload (just the claims).
logoutToken string
// Key to sign the ID Token with.
signKey *signingKey
// If not provided defaults to signKey. Only useful when
// testing invalid signatures.
verificationKey *signingKey
config goidc.Config
wantErr bool
}
type testVerifier struct {
jwk jose.JSONWebKey
}
func (t *testVerifier) VerifySignature(ctx context.Context, jwt string) ([]byte, error) {
jws, err := jose.ParseSigned(jwt)
if err != nil {
return nil, fmt.Errorf("oidc: malformed jwt: %v", err)
}
return jws.Verify(&t.jwk)
}
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"
var ks goidc.KeySet
if v.verificationKey == nil {
ks = &testVerifier{v.signKey.jwk()}
} else {
ks = &testVerifier{v.verificationKey.jwk()}
}
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) {
_, err := l.runGetToken(t)
if err != nil && !l.wantErr {
t.Errorf("%v", err)
}
if err == nil && l.wantErr {
t.Errorf("expected error")
}
}
func newRSAKey(t testing.TB) *signingKey {
priv, err := rsa.GenerateKey(rand.Reader, 1028)
if err != nil {
t.Fatal(err)
}
return &signingKey{"", priv, priv.Public(), jose.RS256}
}
func newECDSAKey(t *testing.T) *signingKey {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
return &signingKey{"", priv, priv.Public(), jose.ES256}
}
+16
View File
@@ -0,0 +1,16 @@
package oidc
// JOSE asymmetric signing algorithm values as defined by RFC 7518
//
// see: https://tools.ietf.org/html/rfc7518#section-3.1
const (
RS256 = "RS256" // RSASSA-PKCS-v1.5 using SHA-256
RS384 = "RS384" // RSASSA-PKCS-v1.5 using SHA-384
RS512 = "RS512" // RSASSA-PKCS-v1.5 using SHA-512
ES256 = "ES256" // ECDSA using P-256 and SHA-256
ES384 = "ES384" // ECDSA using P-384 and SHA-384
ES512 = "ES512" // ECDSA using P-521 and SHA-512
PS256 = "PS256" // RSASSA-PSS using SHA256 and MGF1-SHA256
PS384 = "PS384" // RSASSA-PSS using SHA384 and MGF1-SHA384
PS512 = "PS512" // RSASSA-PSS using SHA512 and MGF1-SHA512
)
+39
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strings"
"github.com/golang-jwt/jwt/v4"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
)
@@ -53,6 +54,44 @@ type ProviderMetadata struct {
//claim_types_supported
}
// Logout Token defines an logout Token
type LogoutToken struct {
// The URL of the server which issued this token. OpenID Connect
// requires this value always be identical to the URL used for
// initial discovery.
//
// Note: Because of a known issue with Google Accounts' implementation
// this value may differ when using Google.
//
// See: https://developers.google.com/identity/protocols/OpenIDConnect#obtainuserinfo
Issuer string `json:"iss"` // example "https://server.example.com"
// A unique string which identifies the end user.
Subject string `json:"sub"` //"248289761001"
// The client ID, or set of client IDs, that this token is issued for. For
// common uses, this is the client that initialized the auth flow.
//
// This package ensures the audience contains an expected value.
Audience jwt.ClaimStrings `json:"aud"` // "s6BhdRkqt3"
// When the token was issued by the provider.
IssuedAt *jwt.NumericDate `json:"iat"`
// The Session Id
SessionId string `json:"sid"`
Events LogoutEvent `json:"events"`
// Jwt Id
JwtID string `json:"jti"`
}
// LogoutEvent defines a logout Event
type LogoutEvent struct {
Event *struct{} `json:"http://schemas.openid.net/event/backchannel-logout"`
}
func GetIDPMetadata(logger log.Logger, client *http.Client, idpURI string) (ProviderMetadata, error) {
wellknownURI := strings.TrimSuffix(idpURI, "/") + wellknownPath
+58
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
}
+121
View File
@@ -0,0 +1,121 @@
package oidc
import (
"net/http"
"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.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
// HTTPClient to use for requests
HTTPClient *http.Client
// Logger to use for logging, must be set
Logger log.Logger
// The OpenID Connect Issuer URL
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
// ClientID the client id to expect in tokens. If not set SkipClientIDCheck must be true
// TODO also check in access token
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.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// WithOidcIssuer provides a function to set the openid connect issuer option.
func WithOidcIssuer(val string) Option {
return func(o *Options) {
o.OIDCIssuer = val
}
}
// WithLogger provides a function to set the logger option.
func WithLogger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// WithAccessTokenVerifyMethod provides a function to set the accessTokenVerifyMethod option.
func WithAccessTokenVerifyMethod(val string) Option {
return func(o *Options) {
o.AccessTokenVerifyMethod = val
}
}
// WithHTTPClient provides a function to set the httpClient option.
func WithHTTPClient(val *http.Client) Option {
return func(o *Options) {
o.HTTPClient = val
}
}
// WithJWKSOptions provides a function to set the jwksOptions option.
func WithJWKSOptions(val config.JWKS) Option {
return func(o *Options) {
o.JWKSOptions = val
}
}
// 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) {
o.ClientID = val
}
}
// WithSkipClientIDCheck provides a function to set the skipClientIDCheck option.
func WithSkipClientIDCheck(val bool) Option {
return func(o *Options) {
o.SkipClientIDCheck = val
}
}
// WithConfig provides a function to set the Config 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
}
}
+135 -27
View File
@@ -3,19 +3,22 @@ package command
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
"github.com/cs3org/reva/v2/pkg/token/manager/jwt"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
"github.com/justinas/alice"
"github.com/oklog/run"
"github.com/owncloud/ocis/v2/ocis-pkg/config/configlog"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
pkgmiddleware "github.com/owncloud/ocis/v2/ocis-pkg/middleware"
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
"github.com/owncloud/ocis/v2/ocis-pkg/service/grpc"
"github.com/owncloud/ocis/v2/ocis-pkg/store"
"github.com/owncloud/ocis/v2/ocis-pkg/version"
@@ -36,7 +39,6 @@ import (
"github.com/owncloud/ocis/v2/services/proxy/pkg/userroles"
"github.com/urfave/cli/v2"
microstore "go-micro.dev/v4/store"
"golang.org/x/oauth2"
)
// Server is the entrypoint for the server command.
@@ -49,6 +51,15 @@ func Server(cfg *config.Config) *cli.Command {
return configlog.ReturnFatal(parser.ParseConfig(cfg))
},
Action: func(c *cli.Context) error {
userInfoCache := store.Create(
store.Store(cfg.OIDC.UserinfoCache.Store),
store.TTL(cfg.OIDC.UserinfoCache.TTL),
store.Size(cfg.OIDC.UserinfoCache.Size),
microstore.Nodes(cfg.OIDC.UserinfoCache.Nodes...),
microstore.Database(cfg.OIDC.UserinfoCache.Database),
microstore.Table(cfg.OIDC.UserinfoCache.Table),
)
logger := logging.Configure(cfg.Service.Name, cfg.Log)
err := tracing.Configure(cfg)
if err != nil {
@@ -59,6 +70,27 @@ func Server(cfg *config.Config) *cli.Command {
return err
}
var oidcHTTPClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: cfg.OIDC.Insecure, //nolint:gosec
},
DisableKeepAlives: true,
},
Timeout: time.Second * 10,
}
oidcClient := oidc.NewOIDCClient(
oidc.WithAccessTokenVerifyMethod(cfg.OIDC.AccessTokenVerifyMethod),
oidc.WithLogger(logger),
oidc.WithHTTPClient(oidcHTTPClient),
oidc.WithOidcIssuer(cfg.OIDC.Issuer),
oidc.WithJWKSOptions(cfg.OIDC.JWKS),
oidc.WithClientID(cfg.OIDC.ClientID),
oidc.WithSkipClientIDCheck(cfg.OIDC.SkipClientIDCheck),
)
var (
m = metrics.New()
)
@@ -79,18 +111,28 @@ func Server(cfg *config.Config) *cli.Command {
proxy.Logger(logger),
proxy.Config(cfg),
)
lh := StaticRouteHandler{
prefix: cfg.HTTP.Root,
userInfoCache: userInfoCache,
logger: logger,
config: *cfg,
oidcClient: oidcClient,
proxy: rp,
}
if err != nil {
return fmt.Errorf("Failed to initialize reverse proxy: %w", err)
return fmt.Errorf("failed to initialize reverse proxy: %w", err)
}
{
middlewares := loadMiddlewares(ctx, logger, cfg, userInfoCache)
server, err := proxyHTTP.Server(
proxyHTTP.Handler(rp),
proxyHTTP.Handler(lh.handler()),
proxyHTTP.Logger(logger),
proxyHTTP.Context(ctx),
proxyHTTP.Config(cfg),
proxyHTTP.Metrics(metrics.New()),
proxyHTTP.Middlewares(loadMiddlewares(ctx, logger, cfg)),
proxyHTTP.Middlewares(middlewares),
)
if err != nil {
@@ -137,7 +179,86 @@ func Server(cfg *config.Config) *cli.Command {
}
}
func loadMiddlewares(ctx context.Context, logger log.Logger, cfg *config.Config) alice.Chain {
// StaticRouteHandler defines a Route Handler for static routes
type StaticRouteHandler struct {
prefix string
proxy http.Handler
userInfoCache microstore.Store
logger log.Logger
config config.Config
oidcClient oidc.OIDCClient
}
func (h *StaticRouteHandler) handler() http.Handler {
m := chi.NewMux()
m.Route(h.prefix, func(r chi.Router) {
// Wrapper for backchannel logout
r.Post("/backchannel_logout", h.backchannelLogout)
// TODO: migrate oidc well knowns here in a second wrapper
r.HandleFunc("/*", h.proxy.ServeHTTP)
})
// This is commented out due to a race issue in chi
//var methods = []string{"PROPFIND", "DELETE", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK", "REPORT"}
//for _, k := range methods {
// chi.RegisterMethod(k)
//}
// To avoid using the chi.RegisterMethod() this is basically a catchAll for all HTTP Methods that are not
// covered in chi by default
m.MethodNotAllowed(h.proxy.ServeHTTP)
return m
}
type jse struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// handle backchannel logout requests as per https://openid.net/specs/openid-connect-backchannel-1_0.html#BCRequest
func (h *StaticRouteHandler) backchannelLogout(w http.ResponseWriter, r *http.Request) {
// parse the application/x-www-form-urlencoded POST request
if err := r.ParseForm(); err != nil {
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: err.Error()})
return
}
logoutToken, err := h.oidcClient.VerifyLogoutToken(r.Context(), r.PostFormValue("logout_token"))
if err != nil {
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: err.Error()})
return
}
records, err := h.userInfoCache.Read(logoutToken.SessionId)
if errors.Is(err, microstore.ErrNotFound) || len(records) == 0 {
render.Status(r, http.StatusOK)
render.JSON(w, r, nil)
return
}
for _, record := range records {
err = h.userInfoCache.Delete(string(record.Value))
if !errors.Is(err, microstore.ErrNotFound) {
// Spec requires us to return a 400 BadRequest when the session could not be destroyed
h.logger.Err(err).Msg("could not delete user info from cache")
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, jse{Error: "invalid_request", ErrorDescription: err.Error()})
return
}
}
// we can ignore errors when cleaning up the lookup table
_ = h.userInfoCache.Delete(logoutToken.SessionId)
render.Status(r, http.StatusOK)
render.JSON(w, r, nil)
}
func loadMiddlewares(ctx context.Context, logger log.Logger, cfg *config.Config, userInfoCache microstore.Store) alice.Chain {
rolesClient := settingssvc.NewRoleService("com.owncloud.api.settings", grpc.DefaultClient())
revaClient, err := pool.GetGatewayServiceClient(cfg.Reva.Address, cfg.Reva.GetRevaOptions()...)
if err != nil {
@@ -212,32 +333,19 @@ func loadMiddlewares(ctx context.Context, logger log.Logger, cfg *config.Config)
})
}
cache := store.Create(
store.Store(cfg.OIDC.UserinfoCache.Store),
store.TTL(cfg.OIDC.UserinfoCache.TTL),
store.Size(cfg.OIDC.UserinfoCache.Size),
microstore.Nodes(cfg.OIDC.UserinfoCache.Nodes...),
microstore.Database(cfg.OIDC.UserinfoCache.Database),
microstore.Table(cfg.OIDC.UserinfoCache.Table),
)
authenticators = append(authenticators, middleware.NewOIDCAuthenticator(
middleware.Logger(logger),
middleware.Cache(cache),
middleware.UserInfoCache(userInfoCache),
middleware.DefaultAccessTokenTTL(cfg.OIDC.UserinfoCache.TTL),
middleware.HTTPClient(oidcHTTPClient),
middleware.OIDCIss(cfg.OIDC.Issuer),
middleware.JWKSOptions(cfg.OIDC.JWKS),
middleware.AccessTokenVerifyMethod(cfg.OIDC.AccessTokenVerifyMethod),
middleware.OIDCProviderFunc(func() (middleware.OIDCProvider, error) {
// Initialize a provider by specifying the issuer URL.
// it will fetch the keys from the issuer using the .well-known
// endpoint
return oidc.NewProvider(
context.WithValue(ctx, oauth2.HTTPClient, oidcHTTPClient),
cfg.OIDC.Issuer,
)
}),
middleware.OIDCClient(oidc.NewOIDCClient(
oidc.WithAccessTokenVerifyMethod(cfg.OIDC.AccessTokenVerifyMethod),
oidc.WithLogger(logger),
oidc.WithHTTPClient(oidcHTTPClient),
oidc.WithOidcIssuer(cfg.OIDC.Issuer),
oidc.WithJWKSOptions(cfg.OIDC.JWKS),
)),
))
authenticators = append(authenticators, middleware.PublicShareAuthenticator{
Logger: logger,
+2
View File
@@ -108,6 +108,8 @@ type OIDC struct {
UserinfoCache *Cache `yaml:"user_info_cache"`
JWKS JWKS `yaml:"jwks"`
RewriteWellKnown bool `yaml:"rewrite_well_known" env:"PROXY_OIDC_REWRITE_WELLKNOWN" desc:"Enables rewriting the /.well-known/openid-configuration to the configured OIDC issuer. Needed by the Desktop Client, Android Client and iOS Client to discover the OIDC provider."`
ClientID string `yaml:"client_id" env:"OCIS_OIDC_CLIENT_ID;PROXY_OIDC_CLIENT_ID" desc:"OIDC client ID, which ownCloud Web uses. This client needs to be set up in your external IDP (has no effect when using the builtin IDP)."`
SkipClientIDCheck bool `yaml:"skip_client_id_check" env:"PROXY_OIDC_SKIP_CLIENT_ID_CHECK" desc:"If true will skip checking the configured client ID is present in audience claims. See following chapter for more details: https://openid.net/specs/openid-connect-core-1_0.html#IDToken"`
}
type JWKS struct {
@@ -53,6 +53,7 @@ func DefaultConfig() *config.Config {
RefreshTimeout: 10, // seconds
RefreshUnknownKID: true,
},
ClientID: "web",
},
PolicySelector: nil,
RoleAssignment: config.RoleAssignment{
+20 -122
View File
@@ -3,17 +3,14 @@ package middleware
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
"github.com/MicahParks/keyfunc"
gOidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v4"
"github.com/pkg/errors"
"github.com/shamaton/msgpack/v2"
@@ -27,26 +24,18 @@ const (
_bearerPrefix = "Bearer "
)
// OIDCProvider used to mock the oidc provider during tests
type OIDCProvider interface {
UserInfo(ctx context.Context, ts oauth2.TokenSource) (*gOidc.UserInfo, error)
}
// NewOIDCAuthenticator returns a ready to use authenticator which can handle OIDC authentication.
func NewOIDCAuthenticator(opts ...Option) *OIDCAuthenticator {
options := newOptions(opts...)
return &OIDCAuthenticator{
Logger: options.Logger,
userInfoCache: options.Cache,
userInfoCache: options.UserInfoCache,
DefaultTokenCacheTTL: options.DefaultAccessTokenTTL,
HTTPClient: options.HTTPClient,
OIDCIss: options.OIDCIss,
ProviderFunc: options.OIDCProviderFunc,
JWKSOptions: options.JWKS,
oidcClient: options.OIDCClient,
AccessTokenVerifyMethod: options.AccessTokenVerifyMethod,
providerLock: &sync.Mutex{},
jwksLock: &sync.Mutex{},
}
}
@@ -57,21 +46,14 @@ type OIDCAuthenticator struct {
OIDCIss string
userInfoCache store.Store
DefaultTokenCacheTTL time.Duration
ProviderFunc func() (OIDCProvider, error)
oidcClient oidc.OIDCClient
AccessTokenVerifyMethod string
JWKSOptions config.JWKS
providerLock *sync.Mutex
provider OIDCProvider
jwksLock *sync.Mutex
JWKS *keyfunc.JWKS
}
func (m *OIDCAuthenticator) getClaims(token string, req *http.Request) (map[string]interface{}, error) {
var claims map[string]interface{}
// usea 64 bytes long hash to have 256-bit collision resistance.
// use a 64 bytes long hash to have 256-bit collision resistance.
hash := make([]byte, 64)
sha3.ShakeSum256(hash, []byte(token))
encodedHash := base64.URLEncoding.EncodeToString(hash)
@@ -87,7 +69,8 @@ func (m *OIDCAuthenticator) getClaims(token string, req *http.Request) (map[stri
}
m.Logger.Error().Err(err).Msg("could not unmarshal userinfo")
}
aClaims, err := m.verifyAccessToken(token)
aClaims, _, err := m.oidcClient.VerifyAccessToken(req.Context(), token)
if err != nil {
return nil, errors.Wrap(err, "failed to verify access token")
}
@@ -96,7 +79,7 @@ func (m *OIDCAuthenticator) getClaims(token string, req *http.Request) (map[stri
AccessToken: token,
}
userInfo, err := m.getProvider().UserInfo(
userInfo, err := m.oidcClient.UserInfo(
context.WithValue(req.Context(), oauth2.HTTPClient, m.HTTPClient),
oauth2.StaticTokenSource(oauth2Token),
)
@@ -120,6 +103,18 @@ func (m *OIDCAuthenticator) getClaims(token string, req *http.Request) (map[stri
if err != nil {
m.Logger.Error().Err(err).Msg("failed to write to userinfo cache")
}
if sid, ok := claims["sid"]; ok {
// reuse user cache for session id lookup
err = m.userInfoCache.Write(&store.Record{
Key: fmt.Sprintf("%s", sid),
Value: []byte(encodedHash),
Expiry: time.Until(expiration),
})
if err != nil {
m.Logger.Error().Err(err).Msg("failed to write session lookup cache")
}
}
}
}()
@@ -127,44 +122,6 @@ func (m *OIDCAuthenticator) getClaims(token string, req *http.Request) (map[stri
return claims, nil
}
func (m OIDCAuthenticator) verifyAccessToken(token string) (jwt.RegisteredClaims, error) {
switch m.AccessTokenVerifyMethod {
case config.AccessTokenVerificationJWT:
return m.verifyAccessTokenJWT(token)
case config.AccessTokenVerificationNone:
m.Logger.Debug().Msg("Access Token verification disabled")
return jwt.RegisteredClaims{}, nil
default:
m.Logger.Error().Str("access_token_verify_method", m.AccessTokenVerifyMethod).Msg("Unknown Access Token verification setting")
return jwt.RegisteredClaims{}, errors.New("Unknown Access Token Verification method")
}
}
// verifyAccessTokenJWT tries to parse and verify the access token as a JWT.
func (m OIDCAuthenticator) verifyAccessTokenJWT(token string) (jwt.RegisteredClaims, error) {
var claims jwt.RegisteredClaims
jwks := m.getKeyfunc()
if jwks == nil {
return claims, errors.New("Error initializing jwks keyfunc")
}
_, err := jwt.ParseWithClaims(token, &claims, jwks.Keyfunc)
m.Logger.Debug().Interface("access token", &claims).Msg("parsed access token")
if err != nil {
m.Logger.Info().Err(err).Msg("Failed to parse/verify the access token.")
return claims, err
}
if !claims.VerifyIssuer(m.OIDCIss, true) {
vErr := jwt.ValidationError{}
vErr.Inner = jwt.ErrTokenInvalidIssuer
vErr.Errors |= jwt.ValidationErrorIssuer
return claims, vErr
}
return claims, nil
}
// extractExpiration tries to extract the expriration time from the access token
// If the access token does not have an exp claim it will fallback to the configured
// default expiration
@@ -186,56 +143,6 @@ func (m OIDCAuthenticator) shouldServe(req *http.Request) bool {
return strings.HasPrefix(header, _bearerPrefix)
}
func (m *OIDCAuthenticator) getKeyfunc() *keyfunc.JWKS {
m.jwksLock.Lock()
defer m.jwksLock.Unlock()
if m.JWKS == nil {
oidcMetadata, err := oidc.GetIDPMetadata(m.Logger, m.HTTPClient, m.OIDCIss)
if err != nil {
m.Logger.Error().Err(err).Msg("failed to decode provider openid-configuration")
return nil
}
m.Logger.Debug().Str("jwks", oidcMetadata.JwksURI).Msg("discovered jwks endpoint")
options := keyfunc.Options{
Client: m.HTTPClient,
RefreshErrorHandler: func(err error) {
m.Logger.Error().Err(err).Msg("There was an error with the jwt.Keyfunc")
},
RefreshInterval: time.Minute * time.Duration(m.JWKSOptions.RefreshInterval),
RefreshRateLimit: time.Second * time.Duration(m.JWKSOptions.RefreshRateLimit),
RefreshTimeout: time.Second * time.Duration(m.JWKSOptions.RefreshTimeout),
RefreshUnknownKID: m.JWKSOptions.RefreshUnknownKID,
}
m.JWKS, err = keyfunc.Get(oidcMetadata.JwksURI, options)
if err != nil {
m.JWKS = nil
m.Logger.Error().Err(err).Msg("Failed to create JWKS from resource at the given URL.")
return nil
}
}
return m.JWKS
}
func (m *OIDCAuthenticator) getProvider() OIDCProvider {
m.providerLock.Lock()
defer m.providerLock.Unlock()
if m.provider == nil {
// Lazily initialize a provider
// provider needs to be cached as when it is created
// it will fetch the keys from the issuer using the .well-known
// endpoint
provider, err := m.ProviderFunc()
if err != nil {
m.Logger.Error().Err(err).Msg("could not initialize oidcAuth provider")
return nil
}
m.provider = provider
}
return m.provider
}
// Authenticate implements the authenticator interface to authenticate requests via oidc auth.
func (m *OIDCAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
// there is no bearer token on the request,
@@ -245,15 +152,6 @@ func (m *OIDCAuthenticator) Authenticate(r *http.Request) (*http.Request, bool)
// implement an early return here for paths we can't authenticate in this authenticator.
return nil, false
}
if m.getProvider() == nil {
return nil, false
}
// Force init of jwks keyfunc if needed (contacts the .well-known and jwks endpoints on first call)
if m.AccessTokenVerifyMethod == config.AccessTokenVerificationJWT && m.getKeyfunc() == nil {
return nil, false
}
token := strings.TrimPrefix(r.Header.Get(_headerAuthorization), _bearerPrefix)
claims, err := m.getClaims(token, r)
+10 -16
View File
@@ -6,6 +6,7 @@ import (
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/ocis-pkg/oidc"
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
storesvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/store/v0"
"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
@@ -34,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
OIDCProviderFunc func() (OIDCProvider, error)
OIDCClient oidc.OIDCClient
// OIDCIss is the oidcAuth-issuer
OIDCIss string
// RevaGatewayClient to send requests to the reva gateway
@@ -53,8 +54,8 @@ type Options struct {
EnableBasicAuth bool
// DefaultAccessTokenTTL is used to calculate the expiration when an access token has no expiration set
DefaultAccessTokenTTL time.Duration
// Cache sets the access token cache store
Cache store.Store
// UserInfoCache sets the access token cache store
UserInfoCache store.Store
// CredentialsByUserAgent sets the auth challenges on a per user-agent basis
CredentialsByUserAgent map[string]string
// AccessTokenVerifyMethod configures how access_tokens should be verified but the oidc_auth middleware.
@@ -113,10 +114,10 @@ func SettingsRoleService(rc settingssvc.RoleService) Option {
}
}
// OIDCProviderFunc provides a function to set the the oidc provider function option.
func OIDCProviderFunc(f func() (OIDCProvider, error)) Option {
// OIDCClient provides a function to set the the oidc client option.
func OIDCClient(val oidc.OIDCClient) Option {
return func(o *Options) {
o.OIDCProviderFunc = f
o.OIDCClient = val
}
}
@@ -190,10 +191,10 @@ func DefaultAccessTokenTTL(ttl time.Duration) Option {
}
}
// Cache provides a function to set the Cache
func Cache(val store.Store) Option {
// UserInfoCache provides a function to set the UserInfoCache
func UserInfoCache(val store.Store) Option {
return func(o *Options) {
o.Cache = val
o.UserInfoCache = val
}
}
@@ -218,13 +219,6 @@ func AccessTokenVerifyMethod(method string) Option {
}
}
// JWKSOptions sets the options for fetching the JWKS from the IDP
func JWKSOptions(jo config.JWKS) Option {
return func(o *Options) {
o.JWKS = jo
}
}
// RoleQuotas sets the role quota mapping setting
func RoleQuotas(roleQuotas map[string]uint64) Option {
return func(o *Options) {
+1 -1
View File
@@ -67,7 +67,7 @@ type WebConfig struct {
type OIDC struct {
MetadataURL string `json:"metadata_url,omitempty" yaml:"metadata_url" env:"WEB_OIDC_METADATA_URL" desc:"URL for the OIDC well-known configuration endpoint. Defaults to the oCIS API URL + \"/.well-known/openid-configuration\"."`
Authority string `json:"authority,omitempty" yaml:"authority" env:"OCIS_URL;OCIS_OIDC_ISSUER;WEB_OIDC_AUTHORITY" desc:"URL of the OIDC issuer. It defaults to URL of the builtin IDP."`
ClientID string `json:"client_id,omitempty" yaml:"client_id" env:"WEB_OIDC_CLIENT_ID" desc:"OIDC client ID, which ownCloud Web uses. This client needs to be set up in your IDP."`
ClientID string `json:"client_id,omitempty" yaml:"client_id" env:"OCIS_OIDC_CLIENT_ID;WEB_OIDC_CLIENT_ID" desc:"OIDC client ID, which ownCloud Web uses. This client needs to be set up in your IDP (has no effect when using the builtin IDP)."`
ResponseType string `json:"response_type,omitempty" yaml:"response_type" env:"WEB_OIDC_RESPONSE_TYPE" desc:"OIDC response type to use for authentication."`
Scope string `json:"scope,omitempty" yaml:"scope" env:"WEB_OIDC_SCOPE" desc:"OIDC scopes to request during authentication to authorize access to user details. Defaults to 'openid profile email'. Values are separated by blank. More example values but not limited to are 'address' or 'phone' etc."`
}