From 014308ddc967d56295ece7ea5d3e514311d65b0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20Friedrich=20Dreyer?= Date: Wed, 12 Apr 2023 11:33:55 +0200 Subject: [PATCH] introduce oidc client, based on coreos go-oidc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jörn Friedrich Dreyer --- ocis-pkg/oidc/client.go | 230 +++++++++++++++++++++++++++++++++++++++ ocis-pkg/oidc/jose.go | 16 +++ ocis-pkg/oidc/options.go | 41 +++++++ 3 files changed, 287 insertions(+) create mode 100644 ocis-pkg/oidc/client.go create mode 100644 ocis-pkg/oidc/jose.go create mode 100644 ocis-pkg/oidc/options.go diff --git a/ocis-pkg/oidc/client.go b/ocis-pkg/oidc/client.go new file mode 100644 index 0000000000..deb1daeda0 --- /dev/null +++ b/ocis-pkg/oidc/client.go @@ -0,0 +1,230 @@ +package oidc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "mime" + "net/http" + "strings" + + gOidc "github.com/coreos/go-oidc/v3/oidc" + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "golang.org/x/oauth2" +) + +// OIDCProvider used to mock the oidc provider during tests +type OIDCProvider interface { + UserInfo(ctx context.Context, ts oauth2.TokenSource) (*UserInfo, error) +} + +// KeySet is a set of publc 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 { + issuer string + provider ProviderMetadata + skipIssuerValidation bool + remoteKeySet KeySet + algorithms []string + // Logger to use for logging, must be set + Logger log.Logger + + client *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 OIDC client for the given issuer +func NewOIDCClient(opts ...Option) OIDCProvider { + options := newOptions(opts...) + + return &oidcClient{ + Logger: options.Logger, + issuer: options.OidcIssuer, + } +} + +func (c *oidcClient) lookupWellKnownOpenidConfiguration(ctx context.Context) error { + + wellKnown := strings.TrimSuffix(c.issuer, "/") + "/.well-known/openid-configuration" + req, err := http.NewRequest("GET", wellKnown, nil) + if err != nil { + return err + } + resp, err := c.client.Do(req.WithContext(ctx)) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := ioutil.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(ctx, p.JwksURI) + + return nil +} + +type stringAsBool bool + +func (sb *stringAsBool) UnmarshalJSON(b []byte) error { + switch string(b) { + case "true", `"true"`: + *sb = true + case "false", `"false"`: + *sb = false + default: + return errors.New("invalid value for boolean") + } + 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󧳁 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) +} + +func (c *oidcClient) UserInfo(ctx context.Context, tokenSource oauth2.TokenSource) (*UserInfo, error) { + if c.provider.UserinfoEndpoint == "" { + // try lazy initialization TODO use sync.once + 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.client.Do(req.WithContext(ctx)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := ioutil.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, _, parseErr := mime.ParseMediaType(ct) + if parseErr == nil && mediaType == "application/jwt" { + payload, err := c.remoteKeySet.VerifySignature(ctx, string(body)) + if err != nil { + return nil, fmt.Errorf("oidc: invalid userinfo jwt signature %v", err) + } + body = payload + } + + var userInfo userInfoRaw + if err := json.Unmarshal(body, &userInfo); err != nil { + return nil, fmt.Errorf("oidc: failed to decode userinfo: %v", err) + } + return &UserInfo{ + Subject: userInfo.Subject, + Profile: userInfo.Profile, + Email: userInfo.Email, + EmailVerified: bool(userInfo.EmailVerified), + claims: body, + }, 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, _, parseErr := mime.ParseMediaType(ct) + if parseErr == 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) +} diff --git a/ocis-pkg/oidc/jose.go b/ocis-pkg/oidc/jose.go new file mode 100644 index 0000000000..8afa895c1c --- /dev/null +++ b/ocis-pkg/oidc/jose.go @@ -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 +) diff --git a/ocis-pkg/oidc/options.go b/ocis-pkg/oidc/options.go new file mode 100644 index 0000000000..d7876d0962 --- /dev/null +++ b/ocis-pkg/oidc/options.go @@ -0,0 +1,41 @@ +package oidc + +import ( + "github.com/owncloud/ocis/v2/ocis-pkg/log" +) + +// Option defines a single option function. +type Option func(o *Options) + +// Options defines the available options for this package. +type Options struct { + // Logger to use for logging, must be set + Logger log.Logger + // The OpenID Connect Issuer URL + OidcIssuer string +} + +// newOptions initializes the available default options. +func newOptions(opts ...Option) Options { + opt := Options{} + + for _, o := range opts { + o(&opt) + } + + return opt +} + +// WithLogger 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 + } +}