diff --git a/README.md b/README.md index b39443fb..8e1eb6ae 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,7 @@ metadata: # This is the human readable, and mutable, name that will get displayed in clients. # It is expected to exist on all CRD backed resources. unikorn-cloud.org/name: acme.com +spec: {} ``` This will provision fairly quickly, you can extract the organization's namespace via: diff --git a/charts/identity/Chart.yaml b/charts/identity/Chart.yaml index 1a2da572..2dc47eb8 100644 --- a/charts/identity/Chart.yaml +++ b/charts/identity/Chart.yaml @@ -4,12 +4,12 @@ description: A Helm chart for deploying Unikorn's IdP type: application -version: v0.2.29 -appVersion: v0.2.29 +version: v0.2.30 +appVersion: v0.2.30 icon: https://raw.githubusercontent.com/unikorn-cloud/assets/main/images/logos/dark-on-light/icon.png dependencies: - name: unikorn-common - version: v0.1.6 + version: v0.1.8 repository: https://unikorn-cloud.github.io/helm-common diff --git a/charts/identity/templates/identity/ingress.yaml b/charts/identity/templates/identity/ingress.yaml index 38c53d24..41ca6c49 100644 --- a/charts/identity/templates/identity/ingress.yaml +++ b/charts/identity/templates/identity/ingress.yaml @@ -6,6 +6,7 @@ metadata: {{- include "unikorn.labels" . | nindent 4 }} annotations: {{- include "unikorn.ingress.clusterIssuer.annotations" . | nindent 4 }} + {{- include "unikorn.ingress.mtls.annotations" . | nindent 4 }} {{- if (include "unikorn.ingress.externalDNS" .) }} external-dns.alpha.kubernetes.io/hostname: {{ include "unikorn.identity.host" . }} {{- end }} diff --git a/charts/identity/templates/organization-controller/deployment.yaml b/charts/identity/templates/organization-controller/deployment.yaml index 2474ed65..2f0bb24e 100644 --- a/charts/identity/templates/organization-controller/deployment.yaml +++ b/charts/identity/templates/organization-controller/deployment.yaml @@ -17,6 +17,8 @@ spec: containers: - name: unikorn-organization-controller image: {{ include "unikorn.organizationControllerImage" . }} + args: + {{- include "unikorn.otlp.flags" . | nindent 8 }} resources: requests: cpu: 50m diff --git a/charts/identity/templates/project-controller/deployment.yaml b/charts/identity/templates/project-controller/deployment.yaml index b7f0a259..5ff55d95 100644 --- a/charts/identity/templates/project-controller/deployment.yaml +++ b/charts/identity/templates/project-controller/deployment.yaml @@ -17,6 +17,8 @@ spec: containers: - name: unikorn-project-controller image: {{ include "unikorn.projectControllerImage" . }} + args: + {{- include "unikorn.otlp.flags" . | nindent 8 }} resources: requests: cpu: 50m diff --git a/charts/identity/values.yaml b/charts/identity/values.yaml index 8696e6a9..2d7daa0b 100644 --- a/charts/identity/values.yaml +++ b/charts/identity/values.yaml @@ -75,7 +75,6 @@ roles: # A platform-admin can do anything anywhere. platform-adminstrator: description: Platform administrator - protected: true scopes: global: organizations: [create,read,update,delete] @@ -87,6 +86,14 @@ roles: identities: [create,read,update,delete] kubernetesclustermanagers: [create,read,update,delete] kubernetesclusters: [create,read,update,delete] + # A region admin is a role primarily for the Kubernetes service that + # can manage identities and physical networks on behalf of a cluster. + region-admin: + decription: Region administrator + scopes: + global: + regions: [create,read,update,delete] + identities: [create,read,update,delete] # An administrator can do anything within an organization. adminstrator: description: Organization administrator diff --git a/go.mod b/go.mod index 816cfdf8..2a395dcd 100644 --- a/go.mod +++ b/go.mod @@ -11,10 +11,10 @@ require ( github.com/oapi-codegen/runtime v1.1.1 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.9.0 - github.com/unikorn-cloud/core v0.1.63 + github.com/unikorn-cloud/core v0.1.66 go.opentelemetry.io/otel v1.28.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 go.opentelemetry.io/otel/sdk v1.28.0 + go.opentelemetry.io/otel/trace v1.28.0 golang.org/x/oauth2 v0.21.0 k8s.io/api v0.30.2 k8s.io/apimachinery v0.30.2 @@ -65,8 +65,8 @@ require ( github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 // indirect go.opentelemetry.io/otel/metric v1.28.0 // indirect - go.opentelemetry.io/otel/trace v1.28.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect diff --git a/go.sum b/go.sum index a6461ef5..680a015b 100644 --- a/go.sum +++ b/go.sum @@ -127,8 +127,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/unikorn-cloud/core v0.1.63 h1:Jl/xuoGRKESMXhS1+apcaS/1I776agTyT75BGz9AKBA= -github.com/unikorn-cloud/core v0.1.63/go.mod h1:JcUIQW3+oiZPUQmOlENw3OCi35IBxPKa+J4MbP3TO7k= +github.com/unikorn-cloud/core v0.1.66 h1:5FbosJk2+XsOsvO+nxs7ei/q+5y+uu4PgKtn2W5MnnQ= +github.com/unikorn-cloud/core v0.1.66/go.mod h1:MhA9xeueMB7EJgdyF0rE7lFQSkuHEcoPen6iwt/QCBE= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/pkg/authorization/authenticator.go b/pkg/authorization/authenticator.go deleted file mode 100644 index 0e3d2d62..00000000 --- a/pkg/authorization/authenticator.go +++ /dev/null @@ -1,53 +0,0 @@ -/* -Copyright 2022-2024 EscherCloud. -Copyright 2024 the Unikorn Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package authorization - -import ( - "context" - - "github.com/unikorn-cloud/core/pkg/server/errors" - "github.com/unikorn-cloud/identity/pkg/jose" - "github.com/unikorn-cloud/identity/pkg/oauth2" -) - -// Authenticator provides Keystone authentication functionality. -type Authenticator struct { - // issuer allows creation and validation of JWT bearer tokens. - issuer *jose.JWTIssuer - - // OAuth2 is the oauth2 deletgating authenticator. - OAuth2 *oauth2.Authenticator -} - -// NewAuthenticator returns a new authenticator with required fields populated. -// You must call AddFlags after this. -func NewAuthenticator(issuer *jose.JWTIssuer, oauth2 *oauth2.Authenticator) *Authenticator { - return &Authenticator{ - issuer: issuer, - OAuth2: oauth2, - } -} - -func (a *Authenticator) JWKS(ctx context.Context) (interface{}, error) { - result, err := a.issuer.JWKS(ctx) - if err != nil { - return nil, errors.OAuth2ServerError("unable to generate json web key set").WithError(err) - } - - return result, nil -} diff --git a/pkg/client/client.go b/pkg/client/client.go index 11430375..3c2216d2 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -18,43 +18,24 @@ package client import ( "context" - "crypto/tls" - "crypto/x509" - "errors" - "fmt" "net/http" - "github.com/spf13/pflag" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/propagation" - "github.com/unikorn-cloud/core/pkg/authorization/accesstoken" + coreclient "github.com/unikorn-cloud/core/pkg/client" + "github.com/unikorn-cloud/identity/pkg/middleware/authorization" + "github.com/unikorn-cloud/identity/pkg/middleware/openapi/accesstoken" "github.com/unikorn-cloud/identity/pkg/openapi" - corev1 "k8s.io/api/core/v1" - "sigs.k8s.io/controller-runtime/pkg/client" ) -var ( - // ErrFormatError is returned when a secret doesn't meet the specification. - ErrFormatError = errors.New("secret incorrectly formatted") -) - -type Options struct { - // Host is the identity Host name. - Host string - // CASecretNamespace tells us where to source the CA secret. - CASecretNamespace string - // CASecretName is the root CA secret of the identity endpoint. - CASecretName string -} +type Options = coreclient.HTTPOptions -// AddFlags adds the options to the CLI flags. -func (o *Options) AddFlags(f *pflag.FlagSet) { - f.StringVar(&o.Host, "identity-host", "", "Identity endpoint URL.") - f.StringVar(&o.CASecretNamespace, "identity-ca-secret-namespace", "", "Identity endpoint CA certificate secret namespace.") - f.StringVar(&o.CASecretName, "identity-ca-secret-name", "", "Identity endpoint CA certificate secret.") +// NewOptions must be used to create options for consistency. +func NewOptions() *Options { + return coreclient.NewHTTPOptions("identity") } // Client wraps up the raw OpenAPI client with things to make it useable e.g. @@ -62,70 +43,28 @@ func (o *Options) AddFlags(f *pflag.FlagSet) { type Client struct { // client is a Kubenetes client. client client.Client - // namespace is the namespace the client is running in. - namespace string - // options allows setting of option from the CLI + // options allows setting of options from the CLI options *Options + // clientOptions may be specified to inject client certificates etc. + clientOptions *coreclient.HTTPClientOptions } // New creates a new client. -func New(client client.Client, namespace string, options *Options) *Client { +func New(client client.Client, options *Options, clientOptions *coreclient.HTTPClientOptions) *Client { return &Client{ - client: client, - namespace: namespace, - options: options, + client: client, + options: options, + clientOptions: clientOptions, } } -// tlsClientConfig abstracts away private TLS CAs or self signed certificates. -func (c *Client) tlsClientConfig(ctx context.Context) (*tls.Config, error) { - if c.options.CASecretName == "" { - //nolint:nilnil - return nil, nil - } - - namespace := c.namespace - - if c.options.CASecretNamespace != "" { - namespace = c.options.CASecretNamespace - } - - secret := &corev1.Secret{} - - if err := c.client.Get(ctx, client.ObjectKey{Namespace: namespace, Name: c.options.CASecretName}, secret); err != nil { - return nil, err - } - - if secret.Type != corev1.SecretTypeTLS { - return nil, fmt.Errorf("%w: issuer CA not of type kubernetes.io/tls", ErrFormatError) - } - - cert, ok := secret.Data[corev1.TLSCertKey] - if !ok { - return nil, fmt.Errorf("%w: issuer CA missing tls.crt", ErrFormatError) - } - - certPool := x509.NewCertPool() - - if ok := certPool.AppendCertsFromPEM(cert); !ok { - return nil, fmt.Errorf("%w: failed to load identity CA certificate", ErrFormatError) - } - - config := &tls.Config{ - RootCAs: certPool, - MinVersion: tls.VersionTLS13, - } - - return config, nil -} - -// httpClient returns a new http client that will transparently do oauth2 header +// HTTPClient returns a new http client that will transparently do oauth2 header // injection and refresh token updates. -func (c *Client) httpClient(ctx context.Context) (*http.Client, error) { +func (c *Client) HTTPClient(ctx context.Context) (*http.Client, error) { // Handle non-system CA certificates for the OIDC discovery protocol // and oauth2 token refresh. This will return nil if none is specified // and default to the system roots. - tlsClientConfig, err := c.tlsClientConfig(ctx) + tlsClientConfig, err := coreclient.TLSClientConfig(ctx, c.client, c.options, c.clientOptions) if err != nil { return nil, err } @@ -139,23 +78,28 @@ func (c *Client) httpClient(ctx context.Context) (*http.Client, error) { return client, nil } -// accessTokenInjector implements OAuth2 bearer token authorization. -func accessTokenInjector(ctx context.Context, req *http.Request) error { - req.Header.Set("Authorization", "bearer "+accesstoken.FromContext(ctx)) +// requestMutator implements OAuth2 bearer token authorization. +func RequestMutator(ctx context.Context, req *http.Request) error { + // NOTE: this can legitimately not be set e.g. if we are actually getting + // an access token, which makes the error checking somewhat useless! + if accessToken, err := accesstoken.FromContext(ctx); err == nil { + req.Header.Set("Authorization", "bearer "+accessToken) + } otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header)) + authorization.InjectClientCert(ctx, req.Header) return nil } // Client returns a new OpenAPI client that can be used to access the API. func (c *Client) Client(ctx context.Context) (*openapi.ClientWithResponses, error) { - httpClient, err := c.httpClient(ctx) + httpClient, err := c.HTTPClient(ctx) if err != nil { return nil, err } - client, err := openapi.NewClientWithResponses(c.options.Host, openapi.WithHTTPClient(httpClient), openapi.WithRequestEditorFn(accessTokenInjector)) + client, err := openapi.NewClientWithResponses(c.options.Host(), openapi.WithHTTPClient(httpClient), openapi.WithRequestEditorFn(RequestMutator)) if err != nil { return nil, err } diff --git a/pkg/client/tokenissuer.go b/pkg/client/tokenissuer.go new file mode 100644 index 00000000..885ed7dc --- /dev/null +++ b/pkg/client/tokenissuer.go @@ -0,0 +1,145 @@ +/* +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package client + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/coreos/go-oidc/v3/oidc" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/propagation" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.22.0" + "go.opentelemetry.io/otel/trace" + + coreclient "github.com/unikorn-cloud/core/pkg/client" + "github.com/unikorn-cloud/identity/pkg/middleware/openapi/accesstoken" + identityapi "github.com/unikorn-cloud/identity/pkg/openapi" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ( + ErrResponse = errors.New("unexpected http response") +) + +type TokenIssuer struct { + // client is a Kubernetes client. + client client.Client + // identityOptions allow the identity host and CA to be set. + identityOptions *Options + // clientOptions give access to client certificate information as + // we need to talk to identity to get a token, and then to region + // to ensure cloud identities and networks are provisioned, as well + // as deptovisioning them. + clientOptions *coreclient.HTTPClientOptions + // serviceName for tracing. + serviceName string + // serviceVersion for tracing. + serviceVersion string +} + +func NewTokenIssuer(client client.Client, identityOptions *Options, clientOptions *coreclient.HTTPClientOptions, serviceName, serviceVersion string) *TokenIssuer { + return &TokenIssuer{ + client: client, + identityOptions: identityOptions, + clientOptions: clientOptions, + serviceName: serviceName, + serviceVersion: serviceVersion, + } +} + +// Context issues an access token for the non-user client/service and injects it into the context. +func (a *TokenIssuer) Context(ctx context.Context, traceName string) (context.Context, error) { + identityClient := New(a.client, a.identityOptions, a.clientOptions) + + identityHTTPClient, err := identityClient.HTTPClient(ctx) + if err != nil { + return nil, err + } + + // Pass that to OIDC service discovery... + ctx = oidc.ClientContext(ctx, identityHTTPClient) + + provider, err := oidc.NewProvider(ctx, a.identityOptions.Host()) + if err != nil { + return nil, err + } + + endpoint := provider.Endpoint() + + // Next we delve deeper into oauth2 to perform a TLS client auth grant... + form := url.Values{} + form.Add("grant_type", "client_credentials") + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.TokenURL, bytes.NewBufferString(form.Encode())) + if err != nil { + return nil, err + } + + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // Start a span that covers this client use. + // NOTE: we do this every time around, and don't do any caching so this is safe + // for now. Caching the client leads to having to cache the access token somehow + // and then token rotation when it expires, so don't be too tempted to change this. + attr := []attribute.KeyValue{ + semconv.ServiceName(serviceName), + semconv.ServiceVersion(version), + } + + tracer := otel.GetTracerProvider().Tracer("access token issuer") + + spanContext, span := tracer.Start(ctx, traceName, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(attr...)) + defer span.End() + + ctx = spanContext + + otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(request.Header)) + + response, err := identityHTTPClient.Do(request) + if err != nil { + return nil, err + } + + defer response.Body.Close() + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("%w: status code %d", ErrResponse, response.StatusCode) + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + token := &identityapi.Token{} + + if err := json.Unmarshal(body, &token); err != nil { + return nil, err + } + + return accesstoken.NewContext(ctx, token.AccessToken), nil +} diff --git a/pkg/controllers/organization/manager.go b/pkg/controllers/organization/manager.go index 7e341e65..d0b9114d 100644 --- a/pkg/controllers/organization/manager.go +++ b/pkg/controllers/organization/manager.go @@ -44,9 +44,14 @@ func (*Factory) Metadata() (string, string, string) { return constants.Application, constants.Version, constants.Revision } +// Options returns any options to be added to the CLI flags and passed to the reconciler. +func (*Factory) Options() coremanager.ControllerOptions { + return nil +} + // Reconciler returns a new reconciler instance. -func (*Factory) Reconciler(options *options.Options, manager manager.Manager) reconcile.Reconciler { - return coremanager.NewReconciler(options, manager, organization.New) +func (*Factory) Reconciler(options *options.Options, controllerOptions coremanager.ControllerOptions, manager manager.Manager) reconcile.Reconciler { + return coremanager.NewReconciler(options, controllerOptions, manager, organization.New) } // RegisterWatches adds any watches that would trigger a reconcile. diff --git a/pkg/controllers/project/manager.go b/pkg/controllers/project/manager.go index 17fea6b3..38cce4bb 100644 --- a/pkg/controllers/project/manager.go +++ b/pkg/controllers/project/manager.go @@ -44,9 +44,14 @@ func (*Factory) Metadata() (string, string, string) { return constants.Application, constants.Version, constants.Revision } +// Options returns any options to be added to the CLI flags and passed to the reconciler. +func (*Factory) Options() coremanager.ControllerOptions { + return nil +} + // Reconciler returns a new reconciler instance. -func (*Factory) Reconciler(options *options.Options, manager manager.Manager) reconcile.Reconciler { - return coremanager.NewReconciler(options, manager, project.New) +func (*Factory) Reconciler(options *options.Options, controllerOptions coremanager.ControllerOptions, manager manager.Manager) reconcile.Reconciler { + return coremanager.NewReconciler(options, controllerOptions, manager, project.New) } // RegisterWatches adds any watches that would trigger a reconcile. diff --git a/pkg/handler/groups/client.go b/pkg/handler/groups/client.go index 66168a2c..6c917c21 100644 --- a/pkg/handler/groups/client.go +++ b/pkg/handler/groups/client.go @@ -27,6 +27,7 @@ import ( "github.com/unikorn-cloud/core/pkg/server/errors" unikornv1 "github.com/unikorn-cloud/identity/pkg/apis/unikorn/v1alpha1" "github.com/unikorn-cloud/identity/pkg/handler/organizations" + "github.com/unikorn-cloud/identity/pkg/middleware/authorization" "github.com/unikorn-cloud/identity/pkg/openapi" kerrors "k8s.io/apimachinery/pkg/api/errors" @@ -124,6 +125,11 @@ func (c *Client) Get(ctx context.Context, organizationID, groupID string) (*open } func (c *Client) generate(ctx context.Context, organization *organizations.Meta, in *openapi.GroupWrite) (*unikornv1.Group, error) { + userinfo, err := authorization.UserinfoFromContext(ctx) + if err != nil { + return nil, errors.OAuth2ServerError("userinfo is not set").WithError(err) + } + // Validate roles exist. for _, roleID := range in.Spec.RoleIDs { var resource unikornv1.Role @@ -139,7 +145,7 @@ func (c *Client) generate(ctx context.Context, organization *organizations.Meta, // TODO: validate groups exist. out := &unikornv1.Group{ - ObjectMeta: conversion.NewObjectMetadata(&in.Metadata, organization.Namespace).WithOrganization(organization.ID).Get(ctx), + ObjectMeta: conversion.NewObjectMetadata(&in.Metadata, organization.Namespace, userinfo.Sub).WithOrganization(organization.ID).Get(), Spec: unikornv1.GroupSpec{ RoleIDs: in.Spec.RoleIDs, }, diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go index 544bbbac..3714ee97 100644 --- a/pkg/handler/handler.go +++ b/pkg/handler/handler.go @@ -23,14 +23,15 @@ import ( "net/http" "slices" - "github.com/unikorn-cloud/core/pkg/authorization/userinfo" "github.com/unikorn-cloud/core/pkg/server/errors" - "github.com/unikorn-cloud/identity/pkg/authorization" "github.com/unikorn-cloud/identity/pkg/handler/groups" "github.com/unikorn-cloud/identity/pkg/handler/oauth2providers" "github.com/unikorn-cloud/identity/pkg/handler/organizations" "github.com/unikorn-cloud/identity/pkg/handler/projects" "github.com/unikorn-cloud/identity/pkg/handler/roles" + "github.com/unikorn-cloud/identity/pkg/jose" + "github.com/unikorn-cloud/identity/pkg/middleware/authorization" + "github.com/unikorn-cloud/identity/pkg/oauth2" "github.com/unikorn-cloud/identity/pkg/openapi" "github.com/unikorn-cloud/identity/pkg/rbac" "github.com/unikorn-cloud/identity/pkg/util" @@ -45,8 +46,11 @@ type Handler struct { // namespace is the namespace we are running in. namespace string - // authenticator gives access to authentication and token handling functions. - authenticator *authorization.Authenticator + // issuer allows creation and validation of JWT bearer tokens. + issuer *jose.JWTIssuer + + // oauth2 is the oauth2 deletgating authenticator. + oauth2 *oauth2.Authenticator // rbac gives access to low level rbac functionality. rbac *rbac.RBAC @@ -55,13 +59,14 @@ type Handler struct { options *Options } -func New(client client.Client, namespace string, authenticator *authorization.Authenticator, rbac *rbac.RBAC, options *Options) (*Handler, error) { +func New(client client.Client, namespace string, issuer *jose.JWTIssuer, oauth2 *oauth2.Authenticator, rbac *rbac.RBAC, options *Options) (*Handler, error) { h := &Handler{ - client: client, - namespace: namespace, - authenticator: authenticator, - rbac: rbac, - options: options, + client: client, + namespace: namespace, + issuer: issuer, + oauth2: oauth2, + rbac: rbac, + options: options, } return h, nil @@ -109,9 +114,12 @@ func (h *Handler) GetWellKnownOpenidConfiguration(w http.ResponseWriter, r *http }, TokenEndpointAuthMethodsSupported: []openapi.AuthMethod{ openapi.ClientSecretPost, + openapi.TlsClientAuth, }, GrantTypesSupported: []openapi.GrantType{ openapi.AuthorizationCode, + openapi.ClientCredentials, + openapi.RefreshToken, }, IdTokenSigningAlgValuesSupported: []openapi.SigningAlgorithm{ openapi.ES512, @@ -125,15 +133,15 @@ func (h *Handler) GetWellKnownOpenidConfiguration(w http.ResponseWriter, r *http } func (h *Handler) GetOauth2V2Authorization(w http.ResponseWriter, r *http.Request) { - h.authenticator.OAuth2.Authorization(w, r) + h.oauth2.Authorization(w, r) } func (h *Handler) PostOauth2V2Login(w http.ResponseWriter, r *http.Request) { - h.authenticator.OAuth2.Login(w, r) + h.oauth2.Login(w, r) } func (h *Handler) PostOauth2V2Token(w http.ResponseWriter, r *http.Request) { - result, err := h.authenticator.OAuth2.Token(w, r) + result, err := h.oauth2.Token(w, r) if err != nil { errors.HandleError(w, r, err) return @@ -144,14 +152,19 @@ func (h *Handler) PostOauth2V2Token(w http.ResponseWriter, r *http.Request) { } func (h *Handler) GetOauth2V2Userinfo(w http.ResponseWriter, r *http.Request) { + userinfo, err := authorization.UserinfoFromContext(r.Context()) + if err != nil { + errors.HandleError(w, r, errors.OAuth2ServerError("userinfo is not set").WithError(err)) + } + h.setUncacheable(w) - util.WriteJSONResponse(w, r, http.StatusOK, userinfo.FromContext(r.Context())) + util.WriteJSONResponse(w, r, http.StatusOK, userinfo) } func (h *Handler) GetOauth2V2Jwks(w http.ResponseWriter, r *http.Request) { - result, err := h.authenticator.JWKS(r.Context()) + result, err := h.issuer.JWKS(r.Context()) if err != nil { - errors.HandleError(w, r, err) + errors.HandleError(w, r, errors.OAuth2ServerError("unable to generate json web key set").WithError(err)) return } @@ -160,7 +173,7 @@ func (h *Handler) GetOauth2V2Jwks(w http.ResponseWriter, r *http.Request) { } func (h *Handler) GetOidcCallback(w http.ResponseWriter, r *http.Request) { - h.authenticator.OAuth2.OIDCCallback(w, r) + h.oauth2.OIDCCallback(w, r) } func (h *Handler) GetApiV1Oauth2providers(w http.ResponseWriter, r *http.Request) { @@ -328,7 +341,7 @@ func (h *Handler) GetApiV1OrganizationsOrganizationIDAvailableGroups(w http.Resp return } - result, err := h.authenticator.OAuth2.Groups(w, r) + result, err := h.oauth2.Groups(w, r) if err != nil { errors.HandleError(w, r, err) return diff --git a/pkg/handler/oauth2providers/client.go b/pkg/handler/oauth2providers/client.go index 410b2d8f..6a5dd364 100644 --- a/pkg/handler/oauth2providers/client.go +++ b/pkg/handler/oauth2providers/client.go @@ -26,6 +26,7 @@ import ( "github.com/unikorn-cloud/core/pkg/server/errors" unikornv1 "github.com/unikorn-cloud/identity/pkg/apis/unikorn/v1alpha1" "github.com/unikorn-cloud/identity/pkg/handler/organizations" + "github.com/unikorn-cloud/identity/pkg/middleware/authorization" "github.com/unikorn-cloud/identity/pkg/openapi" kerrors "k8s.io/apimachinery/pkg/api/errors" @@ -126,9 +127,14 @@ func (c *Client) List(ctx context.Context, organizationID string) (openapi.Oauth return convertList(result), nil } -func (c *Client) generate(ctx context.Context, organization *organizations.Meta, in *openapi.Oauth2ProviderWrite) *unikornv1.OAuth2Provider { +func (c *Client) generate(ctx context.Context, organization *organizations.Meta, in *openapi.Oauth2ProviderWrite) (*unikornv1.OAuth2Provider, error) { + userinfo, err := authorization.UserinfoFromContext(ctx) + if err != nil { + return nil, errors.OAuth2ServerError("userinfo is not set").WithError(err) + } + out := &unikornv1.OAuth2Provider{ - ObjectMeta: conversion.NewObjectMetadata(&in.Metadata, organization.Namespace).WithOrganization(organization.ID).Get(ctx), + ObjectMeta: conversion.NewObjectMetadata(&in.Metadata, organization.Namespace, userinfo.Sub).WithOrganization(organization.ID).Get(), Spec: unikornv1.OAuth2ProviderSpec{ Issuer: in.Spec.Issuer, ClientID: in.Spec.ClientID, @@ -136,7 +142,7 @@ func (c *Client) generate(ctx context.Context, organization *organizations.Meta, }, } - return out + return out, nil } func (c *Client) Create(ctx context.Context, organizationID string, request *openapi.Oauth2ProviderWrite) (*openapi.Oauth2ProviderRead, error) { @@ -145,7 +151,10 @@ func (c *Client) Create(ctx context.Context, organizationID string, request *ope return nil, err } - resource := c.generate(ctx, organization, request) + resource, err := c.generate(ctx, organization, request) + if err != nil { + return nil, err + } if err := c.client.Create(ctx, resource); err != nil { return nil, errors.OAuth2ServerError("failed to create oauth2 provider").WithError(err) @@ -165,7 +174,10 @@ func (c *Client) Update(ctx context.Context, organizationID, providerID string, return err } - required := c.generate(ctx, organization, request) + required, err := c.generate(ctx, organization, request) + if err != nil { + return err + } if err := conversion.UpdateObjectMetadata(required, current); err != nil { return errors.OAuth2ServerError("failed to merge metadata").WithError(err) diff --git a/pkg/handler/organizations/client.go b/pkg/handler/organizations/client.go index f79c2258..a5c94a79 100644 --- a/pkg/handler/organizations/client.go +++ b/pkg/handler/organizations/client.go @@ -22,12 +22,12 @@ import ( "strings" unikornv1core "github.com/unikorn-cloud/core/pkg/apis/unikorn/v1alpha1" - "github.com/unikorn-cloud/core/pkg/authorization/userinfo" coreopenapi "github.com/unikorn-cloud/core/pkg/openapi" "github.com/unikorn-cloud/core/pkg/server/conversion" "github.com/unikorn-cloud/core/pkg/server/errors" "github.com/unikorn-cloud/core/pkg/util" unikornv1 "github.com/unikorn-cloud/identity/pkg/apis/unikorn/v1alpha1" + "github.com/unikorn-cloud/identity/pkg/middleware/authorization" "github.com/unikorn-cloud/identity/pkg/openapi" "github.com/unikorn-cloud/identity/pkg/rbac" @@ -150,9 +150,12 @@ func (c *Client) List(ctx context.Context, rbacClient *rbac.RBAC) (openapi.Organ // If we don't have that then we need to use RBAC to get a list of organizations we are // members of and return only them. if err := rbac.AllowGlobalScope(ctx, "organizations", openapi.Read); err != nil { - userinfo := userinfo.FromContext(ctx) + userinfo, err := authorization.UserinfoFromContext(ctx) + if err != nil { + return nil, errors.OAuth2ServerError("userinfo is not set").WithError(err) + } - memberships, err := rbacClient.GetOrganizationMemberships(ctx, userinfo.Subject) + memberships, err := rbacClient.GetOrganizationMemberships(ctx, userinfo.Sub) if err != nil { return nil, err } @@ -186,9 +189,14 @@ func (c *Client) Get(ctx context.Context, organizationID string) (*openapi.Organ return convert(result), nil } -func (c *Client) generate(ctx context.Context, in *openapi.OrganizationWrite) *unikornv1.Organization { +func (c *Client) generate(ctx context.Context, in *openapi.OrganizationWrite) (*unikornv1.Organization, error) { + userinfo, err := authorization.UserinfoFromContext(ctx) + if err != nil { + return nil, errors.OAuth2ServerError("userinfo is not set").WithError(err) + } + out := &unikornv1.Organization{ - ObjectMeta: conversion.NewObjectMetadata(&in.Metadata, c.namespace).Get(ctx), + ObjectMeta: conversion.NewObjectMetadata(&in.Metadata, c.namespace, userinfo.Sub).Get(), } if in.Spec.OrganizationType == openapi.Domain { @@ -208,7 +216,7 @@ func (c *Client) generate(ctx context.Context, in *openapi.OrganizationWrite) *u } } - return out + return out, nil } func (c *Client) Update(ctx context.Context, organizationID string, request *openapi.OrganizationWrite) error { @@ -217,7 +225,10 @@ func (c *Client) Update(ctx context.Context, organizationID string, request *ope return err } - required := c.generate(ctx, request) + required, err := c.generate(ctx, request) + if err != nil { + return err + } if err := conversion.UpdateObjectMetadata(required, current); err != nil { return errors.OAuth2ServerError("failed to merge metadata").WithError(err) diff --git a/pkg/handler/projects/client.go b/pkg/handler/projects/client.go index 267f55cc..e9701dad 100644 --- a/pkg/handler/projects/client.go +++ b/pkg/handler/projects/client.go @@ -29,6 +29,7 @@ import ( "github.com/unikorn-cloud/core/pkg/server/errors" unikornv1 "github.com/unikorn-cloud/identity/pkg/apis/unikorn/v1alpha1" "github.com/unikorn-cloud/identity/pkg/handler/organizations" + "github.com/unikorn-cloud/identity/pkg/middleware/authorization" "github.com/unikorn-cloud/identity/pkg/openapi" kerrors "k8s.io/apimachinery/pkg/api/errors" @@ -128,8 +129,13 @@ func (c *Client) Get(ctx context.Context, organizationID, projectID string) (*op } func (c *Client) generate(ctx context.Context, organization *organizations.Meta, in *openapi.ProjectWrite) (*unikornv1.Project, error) { + userinfo, err := authorization.UserinfoFromContext(ctx) + if err != nil { + return nil, errors.OAuth2ServerError("userinfo is not set").WithError(err) + } + out := &unikornv1.Project{ - ObjectMeta: conversion.NewObjectMetadata(&in.Metadata, organization.Namespace).WithOrganization(organization.ID).Get(ctx), + ObjectMeta: conversion.NewObjectMetadata(&in.Metadata, organization.Namespace, userinfo.Sub).WithOrganization(organization.ID).Get(), } if in.Spec.GroupIDs != nil { diff --git a/pkg/middleware/audit/logging.go b/pkg/middleware/audit/logging.go new file mode 100644 index 00000000..c9ee012a --- /dev/null +++ b/pkg/middleware/audit/logging.go @@ -0,0 +1,164 @@ +/* +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package audit + +import ( + "encoding/json" + "net/http" + "regexp" + "strings" + + "github.com/getkin/kin-openapi/routers" + + "github.com/unikorn-cloud/core/pkg/openapi" + "github.com/unikorn-cloud/core/pkg/server/errors" + "github.com/unikorn-cloud/core/pkg/server/middleware" + "github.com/unikorn-cloud/identity/pkg/middleware/authorization" + + "sigs.k8s.io/controller-runtime/pkg/log" +) + +type Logger struct { + // next defines the next HTTP handler in the chain. + next http.Handler + + // openapi caches the Schema schema. + openapi *openapi.Schema + + // application is the application name. + application string + + // version is the application version. + version string +} + +// Ensure this implements the required interfaces. +var _ http.Handler = &Logger{} + +// New returns an initialized middleware. +func New(next http.Handler, openapi *openapi.Schema, application, version string) *Logger { + return &Logger{ + next: next, + openapi: openapi, + application: application, + version: version, + } +} + +// getResource will resolve to a resource type. +func getResource(w *middleware.LoggingResponseWriter, r *http.Request, route *routers.Route, params map[string]string) *Resource { + // Creates rely on the response containing the resource ID in the response metadata. + if r.Method == http.MethodPost { + // Nothing written, possibly a bug somewhere? + if w.Body() == nil { + return nil + } + + var metadata struct { + Metadata openapi.ResourceReadMetadata `json:"metadata"` + } + + // Not a canonical API resource, possibly a bug somewhere? + if err := json.Unmarshal(w.Body().Bytes(), &metadata); err != nil { + return nil + } + + segments := strings.Split(route.Path, "/") + + return &Resource{ + Type: segments[len(segments)-1], + ID: metadata.Metadata.Id, + } + } + + // Read, updates and deletes you can get the information from the route. + matches := regexp.MustCompile(`/([^/]+)/{([^/}]+)}$`).FindStringSubmatch(route.Path) + if matches == nil { + return nil + } + + return &Resource{ + Type: matches[1], + ID: params[matches[2]], + } +} + +// ServeHTTP implements the http.Handler interface. +func (l *Logger) ServeHTTP(w http.ResponseWriter, r *http.Request) { + route, params, err := l.openapi.FindRoute(r) + if err != nil { + errors.HandleError(w, r, errors.OAuth2ServerError("route lookup failure").WithError(err)) + + return + } + + writer := middleware.NewLoggingResponseWriter(w) + + l.next.ServeHTTP(writer, r) + + // Users and auditors care about things coming, going and changing, who did + // those things and when? Certainly not periodic polling that is par for the + // course. Failures of reads may be indicative of someone trying to do + // something they shouldn't via the API (or indeed a bug in a UI leeting them + // attempt something they are forbidden to do). + if r.Method == http.MethodGet { + return + } + + // If there is not accountibility e.g. a global call, it's not worth logging. + userinfo, err := authorization.UserinfoFromContext(r.Context()) + if err != nil { + return + } + + // If there's no scope, then discard also. + if len(params) == 0 { + return + } + + // If you cannot derive the resource, then discard. + resource := getResource(writer, r, route, params) + if resource == nil { + return + } + + logParams := []any{ + "component", &Component{ + Name: l.application, + Version: l.version, + }, + "actor", &Actor{ + Subject: userinfo.Sub, + }, + "operation", &Operation{ + Verb: r.Method, + }, + "scope", params, + "resource", resource, + "result", &Result{ + Status: writer.StatusCode(), + }, + } + + log.FromContext(r.Context()).Info("audit", logParams...) +} + +func Middleware(openapi *openapi.Schema, application, version string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return New(next, openapi, application, version) + } +} diff --git a/pkg/middleware/audit/types.go b/pkg/middleware/audit/types.go new file mode 100644 index 00000000..1704a7ef --- /dev/null +++ b/pkg/middleware/audit/types.go @@ -0,0 +1,39 @@ +/* +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package audit + +type Component struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type Actor struct { + Subject string `json:"subject"` +} + +type Resource struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` +} + +type Operation struct { + Verb string `json:"verb"` +} + +type Result struct { + Status int `json:"status"` +} diff --git a/pkg/middleware/authorization/clientcert.go b/pkg/middleware/authorization/clientcert.go new file mode 100644 index 00000000..eec9743c --- /dev/null +++ b/pkg/middleware/authorization/clientcert.go @@ -0,0 +1,83 @@ +/* +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package authorization + +import ( + "context" + goerrors "errors" + "net/http" + + "github.com/unikorn-cloud/core/pkg/errors" + "github.com/unikorn-cloud/identity/pkg/util" +) + +type clientCertKeyType int + +const ( + clientCertKey clientCertKeyType = iota +) + +// NewContextWithClientCert is used to propagate the client certificate to other clients. +// The client certificate parameter is passed verbatim from the TLS termination header, so +// should be a url encoded string. +func NewContextWithClientCert(ctx context.Context, clientCert string) context.Context { + return context.WithValue(ctx, clientCertKey, clientCert) +} + +func ClientCertFromContext(ctx context.Context) (string, error) { + if value := ctx.Value(clientCertKey); value != nil { + if clientCert, ok := value.(string); ok { + return clientCert, nil + } + } + + return "", errors.ErrInvalidContext +} + +const ( + clientCertificateHeader = "Unikorn-Client-Certificate" +) + +// ExtractClientCert is called from the API to either propagate an existing +// certificate to the context, or to extract one from headers injected by TLS termination. +func ExtractClientCert(ctx context.Context, header http.Header) (context.Context, error) { + if clientCert := header.Get(clientCertificateHeader); clientCert != "" { + return NewContextWithClientCert(ctx, clientCert), nil + } + + clientCert, err := util.GetClientCertificateHeader(header) + if err != nil { + // Nothing there, don't propagate. + if goerrors.Is(err, util.ErrClientCertificateNotPresent) { + return ctx, nil + } + + // Something went wrong e.g. validation error. + return nil, err + } + + return NewContextWithClientCert(ctx, clientCert), nil +} + +// InjectClientCert is called by clients to propagate the client certificate +// that started the call chain, and thus owns the access token, to the next server. +func InjectClientCert(ctx context.Context, header http.Header) { + clientCert, err := ClientCertFromContext(ctx) + if err == nil { + header.Set(clientCertificateHeader, clientCert) + } +} diff --git a/pkg/middleware/authorization/userinfo.go b/pkg/middleware/authorization/userinfo.go new file mode 100644 index 00000000..4ff8c044 --- /dev/null +++ b/pkg/middleware/authorization/userinfo.go @@ -0,0 +1,43 @@ +/* +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package authorization + +import ( + "context" + + "github.com/unikorn-cloud/core/pkg/errors" + identityapi "github.com/unikorn-cloud/identity/pkg/openapi" +) + +type keyType int + +//nolint:gochecknoglobals +var key keyType + +func NewContextWithUserinfo(ctx context.Context, userinfo *identityapi.Userinfo) context.Context { + return context.WithValue(ctx, key, userinfo) +} + +func UserinfoFromContext(ctx context.Context) (*identityapi.Userinfo, error) { + if value := ctx.Value(key); value != nil { + if userinfo, ok := value.(*identityapi.Userinfo); ok { + return userinfo, nil + } + } + + return nil, errors.ErrInvalidContext +} diff --git a/pkg/middleware/openapi/accesstoken/context.go b/pkg/middleware/openapi/accesstoken/context.go index 885a8965..7d8c6f82 100644 --- a/pkg/middleware/openapi/accesstoken/context.go +++ b/pkg/middleware/openapi/accesstoken/context.go @@ -18,6 +18,8 @@ package accesstoken import ( "context" + + "github.com/unikorn-cloud/core/pkg/errors" ) type keyType int @@ -29,7 +31,12 @@ func NewContext(ctx context.Context, accessToken string) context.Context { return context.WithValue(ctx, key, accessToken) } -func FromContext(ctx context.Context) string { - //nolint:forcetypeassert - return ctx.Value(key).(string) +func FromContext(ctx context.Context) (string, error) { + if value := ctx.Value(key); value != nil { + if accessToken, ok := value.(string); ok { + return accessToken, nil + } + } + + return "", errors.ErrInvalidContext } diff --git a/pkg/middleware/openapi/interfaces.go b/pkg/middleware/openapi/interfaces.go index da1327e8..1450b2c1 100644 --- a/pkg/middleware/openapi/interfaces.go +++ b/pkg/middleware/openapi/interfaces.go @@ -21,7 +21,6 @@ import ( "github.com/getkin/kin-openapi/openapi3filter" - "github.com/unikorn-cloud/core/pkg/authorization/userinfo" "github.com/unikorn-cloud/identity/pkg/openapi" ) @@ -29,7 +28,7 @@ import ( type Authorizer interface { // Authorize checks the request against the OpenAPI security scheme // and returns the access token. - Authorize(authentication *openapi3filter.AuthenticationInput) (string, *userinfo.UserInfo, error) + Authorize(authentication *openapi3filter.AuthenticationInput) (string, *openapi.Userinfo, error) // GetACL retrieves access control information from the subject identified // by the Authorize call. diff --git a/pkg/middleware/openapi/local/authorizer.go b/pkg/middleware/openapi/local/authorizer.go index 6be4f6fa..b97d456f 100644 --- a/pkg/middleware/openapi/local/authorizer.go +++ b/pkg/middleware/openapi/local/authorizer.go @@ -24,11 +24,12 @@ import ( "github.com/getkin/kin-openapi/openapi3filter" - "github.com/unikorn-cloud/core/pkg/authorization/userinfo" "github.com/unikorn-cloud/core/pkg/server/errors" + "github.com/unikorn-cloud/identity/pkg/middleware/authorization" "github.com/unikorn-cloud/identity/pkg/oauth2" "github.com/unikorn-cloud/identity/pkg/openapi" "github.com/unikorn-cloud/identity/pkg/rbac" + "github.com/unikorn-cloud/identity/pkg/util" ) // Authorizer provides OpenAPI based authorization middleware. @@ -62,7 +63,7 @@ func getHTTPAuthenticationScheme(r *http.Request) (string, string, error) { } // authorizeOAuth2 checks APIs that require and oauth2 bearer token. -func (a *Authorizer) authorizeOAuth2(r *http.Request) (string, *userinfo.UserInfo, error) { +func (a *Authorizer) authorizeOAuth2(r *http.Request) (string, *openapi.Userinfo, error) { authorizationScheme, token, err := getHTTPAuthenticationScheme(r) if err != nil { return "", nil, err @@ -84,13 +85,48 @@ func (a *Authorizer) authorizeOAuth2(r *http.Request) (string, *userinfo.UserInf return "", nil, errors.OAuth2AccessDenied("token validation failed").WithError(err) } - ui := userinfo.UserInfo(claims.Claims) + // All API requests will ultimately end up here as service call back + // into the identity service to validate the token presented to the API. + // If the token is bound to a certificate, we also expect the client + // certificate to be presented by the first client in the chain and + // propagated here. + if claims.Config != nil && claims.Config.X509Thumbprint != nil { + certPEM, err := authorization.ClientCertFromContext(r.Context()) + if err != nil { + return "", nil, errors.OAuth2AccessDenied("client certificate not present for bound token").WithError(err) + } + + certificate, err := util.GetClientCertificate(certPEM) + if err != nil { + return "", nil, errors.OAuth2AccessDenied("client certificate parse error").WithError(err) + } + + thumbprint := util.GetClientCertiifcateThumbprint(certificate) + + if thumbprint != *claims.Config.X509Thumbprint { + return "", nil, errors.OAuth2AccessDenied("client certificate mismatch for bound token") + } + } + + exp := int(claims.Expiry.Time().Unix()) + nbf := int(claims.NotBefore.Time().Unix()) + iat := int(claims.IssuedAt.Time().Unix()) + + userinfo := &openapi.Userinfo{ + Iss: &claims.Issuer, + Sub: claims.Subject, + Aud: &claims.Audience[0], + Exp: &exp, + Nbf: &nbf, + Iat: &iat, + Jti: &claims.ID, + } - return token, &ui, nil + return token, userinfo, nil } // Authorize checks the request against the OpenAPI security scheme. -func (a *Authorizer) Authorize(authentication *openapi3filter.AuthenticationInput) (string, *userinfo.UserInfo, error) { +func (a *Authorizer) Authorize(authentication *openapi3filter.AuthenticationInput) (string, *openapi.Userinfo, error) { if authentication.SecurityScheme.Type == "oauth2" { return a.authorizeOAuth2(authentication.RequestValidationInput.Request) } diff --git a/pkg/middleware/openapi/openapi.go b/pkg/middleware/openapi/openapi.go index 50e36a47..08e8168a 100644 --- a/pkg/middleware/openapi/openapi.go +++ b/pkg/middleware/openapi/openapi.go @@ -26,10 +26,10 @@ import ( "github.com/getkin/kin-openapi/openapi3filter" "github.com/getkin/kin-openapi/routers" - "github.com/unikorn-cloud/core/pkg/authorization/accesstoken" - "github.com/unikorn-cloud/core/pkg/authorization/userinfo" "github.com/unikorn-cloud/core/pkg/openapi" "github.com/unikorn-cloud/core/pkg/server/errors" + "github.com/unikorn-cloud/identity/pkg/middleware/authorization" + "github.com/unikorn-cloud/identity/pkg/middleware/openapi/accesstoken" identityapi "github.com/unikorn-cloud/identity/pkg/openapi" "github.com/unikorn-cloud/identity/pkg/rbac" @@ -53,7 +53,7 @@ type Validator struct { accessToken string // userinfo is used for identity and RBAC. - userinfo *userinfo.UserInfo + userinfo *identityapi.Userinfo // err is used to indicate the actual openapi error. err error @@ -170,6 +170,19 @@ func (v *Validator) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Propagate the client certificate now so it's available in the request validation + // in case its required for a bound access token. + ctx, err := authorization.ExtractClientCert(r.Context(), r.Header) + if err != nil { + errors.HandleError(w, r, errors.OAuth2InvalidRequest("certificate propagation failure").WithError(err)) + return + } + + // Make a shallow copy of the request with the new context. OpenAPI validation + // will read the body, and replace it with a new buffer, so be sure to use this + // version from here on. + r = r.WithContext(ctx) + responseValidationInput, err := v.validateRequest(r, route, params) if err != nil { // If the authenticator errored, override whatever openapi spits out. @@ -184,36 +197,30 @@ func (v *Validator) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Propagate authentication/authorization info to the handlers // and the ACL layer to use. - ctx := r.Context() ctx = accesstoken.NewContext(ctx, v.accessToken) - ctx = userinfo.NewContext(ctx, v.userinfo) - - // This parameter is standardized across all services. - organizationID := params["organizationID"] - - var acl *identityapi.Acl + ctx = authorization.NewContextWithUserinfo(ctx, v.userinfo) if v.userinfo != nil { - acl, err = v.authorizer.GetACL(ctx, organizationID, v.userinfo.Subject) + // The organizationID parameter is standardized across all services. + acl, err := v.authorizer.GetACL(ctx, params["organizationID"], v.userinfo.Sub) if err != nil { errors.HandleError(w, r, err) - return } ctx = rbac.NewContext(ctx, acl) } - req := r.WithContext(ctx) + r = r.WithContext(ctx) // Override the writer so we can inspect the contents and status. writer := &bufferingResponseWriter{ next: w, } - v.next.ServeHTTP(writer, req) + v.next.ServeHTTP(writer, r) - v.validateResponse(writer, req, responseValidationInput) + v.validateResponse(writer, r, responseValidationInput) } // Middleware returns a function that generates per-request diff --git a/pkg/middleware/openapi/remote/authorizer.go b/pkg/middleware/openapi/remote/authorizer.go index c52d8026..63c8c786 100644 --- a/pkg/middleware/openapi/remote/authorizer.go +++ b/pkg/middleware/openapi/remote/authorizer.go @@ -19,44 +19,38 @@ package authorizer import ( "context" - "crypto/tls" - "crypto/x509" "net/http" "strconv" "strings" "github.com/coreos/go-oidc/v3/oidc" "github.com/getkin/kin-openapi/openapi3filter" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/propagation" "golang.org/x/oauth2" - "github.com/unikorn-cloud/core/pkg/authorization/userinfo" + coreclient "github.com/unikorn-cloud/core/pkg/client" "github.com/unikorn-cloud/core/pkg/server/errors" identityclient "github.com/unikorn-cloud/identity/pkg/client" "github.com/unikorn-cloud/identity/pkg/middleware/openapi" identityapi "github.com/unikorn-cloud/identity/pkg/openapi" - corev1 "k8s.io/api/core/v1" - "sigs.k8s.io/controller-runtime/pkg/client" ) // Authorizer provides OpenAPI based authorization middleware. type Authorizer struct { - client client.Client - namespace string - options *identityclient.Options + client client.Client + options *identityclient.Options + clientOptions *coreclient.HTTPClientOptions } var _ openapi.Authorizer = &Authorizer{} // NewAuthorizer returns a new authorizer with required parameters. -func NewAuthorizer(client client.Client, namespace string, options *identityclient.Options) *Authorizer { +func NewAuthorizer(client client.Client, options *identityclient.Options, clientOptions *coreclient.HTTPClientOptions) *Authorizer { return &Authorizer{ - client: client, - namespace: namespace, - options: options, + client: client, + options: options, + clientOptions: clientOptions, } } @@ -76,41 +70,6 @@ func getHTTPAuthenticationScheme(r *http.Request) (string, string, error) { return parts[0], parts[1], nil } -type propagationFunc func(r *http.Request) - -type propagatingTransport struct { - base http.Transport - f propagationFunc -} - -func newPropagatingTransport(ctx context.Context) *propagatingTransport { - return &propagatingTransport{ - f: func(r *http.Request) { - otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(r.Header)) - }, - } -} - -func (t *propagatingTransport) Clone() *propagatingTransport { - return &propagatingTransport{ - f: t.f, - } -} - -func (t *propagatingTransport) CloseIdleConnections() { - t.base.CloseIdleConnections() -} - -func (t *propagatingTransport) RegisterProtocol(scheme string, rt http.RoundTripper) { - t.base.RegisterProtocol(scheme, rt) -} - -func (t *propagatingTransport) RoundTrip(req *http.Request) (*http.Response, error) { - t.f(req) - - return t.base.RoundTrip(req) -} - // oidcErrorIsUnauthorized tries to convert the error returned by the OIDC library // into a proper status code, as it doesn't wrap anything useful. // The error looks like "{code} {text code}: {body}". @@ -136,49 +95,23 @@ func oidcErrorIsUnauthorized(err error) bool { return code == http.StatusUnauthorized } -func (a *Authorizer) tlsClientConfig(ctx context.Context) (*tls.Config, error) { - if a.options.CASecretName == "" { - //nolint:nilnil - return nil, nil - } - - namespace := a.namespace - - if a.options.CASecretNamespace != "" { - namespace = a.options.CASecretNamespace - } - - secret := &corev1.Secret{} - - if err := a.client.Get(ctx, client.ObjectKey{Namespace: namespace, Name: a.options.CASecretName}, secret); err != nil { - return nil, errors.OAuth2ServerError("unable to fetch issuer CA").WithError(err) - } - - if secret.Type != corev1.SecretTypeTLS { - return nil, errors.OAuth2ServerError("issuer CA not of type kubernetes.io/tls") - } - - cert, ok := secret.Data[corev1.TLSCertKey] - if !ok { - return nil, errors.OAuth2ServerError("issuer CA missing tls.crt") - } - - certPool := x509.NewCertPool() - - if ok := certPool.AppendCertsFromPEM(cert); !ok { - return nil, errors.OAuth2InvalidRequest("failed to parse oidc issuer CA cert") - } +type requestMutatingTransport struct { + base http.RoundTripper + mutator func(r *http.Request) error +} - config := &tls.Config{ - RootCAs: certPool, - MinVersion: tls.VersionTLS13, +func (t *requestMutatingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if err := t.mutator(req); err != nil { + return nil, err } - return config, nil + return t.base.RoundTrip(req) } // authorizeOAuth2 checks APIs that require and oauth2 bearer token. -func (a *Authorizer) authorizeOAuth2(r *http.Request) (string, *userinfo.UserInfo, error) { +func (a *Authorizer) authorizeOAuth2(r *http.Request) (string, *identityapi.Userinfo, error) { + ctx := r.Context() + authorizationScheme, rawToken, err := getHTTPAuthenticationScheme(r) if err != nil { return "", nil, err @@ -188,26 +121,32 @@ func (a *Authorizer) authorizeOAuth2(r *http.Request) (string, *userinfo.UserInf return "", nil, errors.OAuth2InvalidRequest("authorization scheme not allowed").WithValues("scheme", authorizationScheme) } - // Handle non-public CA certiifcates used in development. - ctx := r.Context() + // The identity client neatly wraps up TLS... + identity := identityclient.New(a.client, a.options, a.clientOptions) - tlsClientConfig, err := a.tlsClientConfig(r.Context()) + client, err := identity.HTTPClient(ctx) if err != nil { return "", nil, err } - transport := newPropagatingTransport(ctx) - transport.base.TLSClientConfig = tlsClientConfig + mutator := func(req *http.Request) error { + return identityclient.RequestMutator(ctx, req) + } - client := &http.Client{ - Transport: transport, + // But it doesn't do request mutation, so we have to slightly hack it by + // making a nested transport. + client = &http.Client{ + Transport: &requestMutatingTransport{ + base: client.Transport, + mutator: mutator, + }, } ctx = oidc.ClientContext(ctx, client) // Perform userinfo call against the identity service that will validate the token - // and also return some information about the user. - provider, err := oidc.NewProvider(ctx, a.options.Host) + // and also return some information about the user that we can use for audit logging. + provider, err := oidc.NewProvider(ctx, a.options.Host()) if err != nil { return "", nil, errors.OAuth2ServerError("oidc service discovery failed").WithError(err) } @@ -226,7 +165,7 @@ func (a *Authorizer) authorizeOAuth2(r *http.Request) (string, *userinfo.UserInf return "", nil, err } - claims := &userinfo.UserInfo{} + claims := &identityapi.Userinfo{} if err := ui.Claims(claims); err != nil { return "", nil, errors.OAuth2ServerError("failed to extrac user information").WithError(err) @@ -236,7 +175,7 @@ func (a *Authorizer) authorizeOAuth2(r *http.Request) (string, *userinfo.UserInf } // Authorize checks the request against the OpenAPI security scheme. -func (a *Authorizer) Authorize(authentication *openapi3filter.AuthenticationInput) (string, *userinfo.UserInfo, error) { +func (a *Authorizer) Authorize(authentication *openapi3filter.AuthenticationInput) (string, *identityapi.Userinfo, error) { if authentication.SecurityScheme.Type == "oauth2" { return a.authorizeOAuth2(authentication.RequestValidationInput.Request) } @@ -247,7 +186,7 @@ func (a *Authorizer) Authorize(authentication *openapi3filter.AuthenticationInpu // GetACL retrieves access control information from the subject identified // by the Authorize call. func (a *Authorizer) GetACL(ctx context.Context, organizationID, subject string) (*identityapi.Acl, error) { - client, err := identityclient.New(a.client, a.namespace, a.options).Client(ctx) + client, err := identityclient.New(a.client, a.options, a.clientOptions).Client(ctx) if err != nil { return nil, errors.OAuth2ServerError("failed to create identity client").WithError(err) } diff --git a/pkg/oauth2/oauth2.go b/pkg/oauth2/oauth2.go index 995152af..8f98ae3b 100644 --- a/pkg/oauth2/oauth2.go +++ b/pkg/oauth2/oauth2.go @@ -39,16 +39,17 @@ import ( "github.com/spf13/pflag" "golang.org/x/oauth2" - "github.com/unikorn-cloud/core/pkg/authorization/userinfo" coreopenapi "github.com/unikorn-cloud/core/pkg/openapi" "github.com/unikorn-cloud/core/pkg/server/errors" "github.com/unikorn-cloud/core/pkg/util/retry" unikornv1 "github.com/unikorn-cloud/identity/pkg/apis/unikorn/v1alpha1" "github.com/unikorn-cloud/identity/pkg/jose" + "github.com/unikorn-cloud/identity/pkg/middleware/authorization" "github.com/unikorn-cloud/identity/pkg/oauth2/providers" providererrors "github.com/unikorn-cloud/identity/pkg/oauth2/providers/errors" "github.com/unikorn-cloud/identity/pkg/openapi" "github.com/unikorn-cloud/identity/pkg/rbac" + "github.com/unikorn-cloud/identity/pkg/util" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" @@ -941,131 +942,133 @@ func (a *Authenticator) oidcIDToken(r *http.Request, code *Code, expiry time.Dur return &idToken, nil } -// Token issues an OAuth2 access token from the provided authorization code. -// -//nolint:cyclop -func (a *Authenticator) Token(w http.ResponseWriter, r *http.Request) (*openapi.Token, error) { - if err := r.ParseForm(); err != nil { - return nil, errors.OAuth2InvalidRequest("failed to parse form data: " + err.Error()) +// TokenAuthorizationCode issues a token based on whether the provided code is correct and +// the client code verifier (PKCS) matches. +func (a *Authenticator) TokenAuthorizationCode(w http.ResponseWriter, r *http.Request) (*openapi.Token, error) { + if err := tokenValidate(r); err != nil { + return nil, err } - //nolint:nestif - if r.Form.Get("grant_type") == "refresh_token" { - // Validate the refresh token and extract the claims. - claims := &RefreshTokenClaims{} + code := &Code{} - if err := a.issuer.DecodeJWEToken(r.Context(), r.Form.Get("refresh_token"), claims, jose.TokenTypeRefreshToken); err != nil { - return nil, errors.OAuth2InvalidGrant("refresh token is invalid or has expired").WithError(err) - } + if err := a.issuer.DecodeJWEToken(r.Context(), r.Form.Get("code"), code, jose.TokenTypeAuthorizationCode); err != nil { + return nil, errors.OAuth2InvalidRequest("failed to parse code: " + err.Error()) + } - // Lookup the provider details, then do a token refresh against that to update - // the access token. - providerResource, err := a.lookupProviderByName(r.Context(), claims.Custom.Provider) - if err != nil { - return nil, err - } + if err := tokenValidateCode(code, r); err != nil { + return nil, err + } - // Quality of life improvement, when you are a road-warrior, you are going - // to get an expired access token almost immediately, and a token refresh - // well before Wifi comes up, so allow retries while DNS errors are - // occurring, within reason. - var provider *oidc.Provider - - //nolint:contextcheck - callback := func() error { - t, err := newOIDCProvider(r.Context(), providerResource) - if err != nil { - return err - } + info := &IssueInfo{ + Issuer: "https://" + r.Host, + Audience: r.Host, + Subject: code.IDToken.OIDCClaimsEmail.Email, + Tokens: &Tokens{ + Provider: code.OAuth2Provider, + Expiry: code.AccessTokenExpiry, + AccessToken: code.AccessToken, + }, + } - provider = t + if code.RefreshToken != "" { + info.Tokens.RefreshToken = &code.RefreshToken + } - return nil - } + tokens, err := a.Issue(r.Context(), info) + if err != nil { + return nil, err + } - retryContext, cancel := context.WithTimeout(r.Context(), 30*time.Second) - defer cancel() + // Handle OIDC. + idToken, err := a.oidcIDToken(r, code, a.options.AccessTokenDuration, oidcHash(tokens.AccessToken)) + if err != nil { + return nil, err + } - if err := retry.Forever().DoWithContext(retryContext, callback); err != nil { - return nil, errors.OAuth2ServerError("failed to perform provider discovery").WithError(err) - } + result := &openapi.Token{ + TokenType: "Bearer", + AccessToken: tokens.AccessToken, + RefreshToken: tokens.RefreshToken, + IdToken: idToken, + ExpiresIn: int(time.Until(tokens.Expiry).Seconds()), + } - refreshToken := &oauth2.Token{ - Expiry: time.Now(), - RefreshToken: claims.Custom.RefreshToken, - } + return result, nil +} - providerTokens, err := a.oidcConfig(r, providerResource, provider.Endpoint(), nil).TokenSource(r.Context(), refreshToken).Token() - if err != nil { - var rerr *oauth2.RetrieveError +// TokenRefreshToken issues a token if the provided refresh token is valid. +func (a *Authenticator) TokenRefreshToken(w http.ResponseWriter, r *http.Request) (*openapi.Token, error) { + // Validate the refresh token and extract the claims. + claims := &RefreshTokenClaims{} - if goerrors.As(err, &rerr) && rerr.ErrorCode == string(coreopenapi.InvalidGrant) { - return nil, errors.OAuth2InvalidGrant("provider refresh token has expired").WithError(err) - } + if err := a.issuer.DecodeJWEToken(r.Context(), r.Form.Get("refresh_token"), claims, jose.TokenTypeRefreshToken); err != nil { + return nil, errors.OAuth2InvalidGrant("refresh token is invalid or has expired").WithError(err) + } - return nil, err - } + // Lookup the provider details, then do a token refresh against that to update + // the access token. + providerResource, err := a.lookupProviderByName(r.Context(), claims.Custom.Provider) + if err != nil { + return nil, err + } - info := &IssueInfo{ - Issuer: "https://" + r.Host, - Audience: r.Host, - Subject: claims.Claims.Subject, - Provider: claims.Custom.Provider, - Tokens: Tokens{ - Expiry: providerTokens.Expiry, - AccessToken: providerTokens.AccessToken, - RefreshToken: providerTokens.RefreshToken, - }, - } + // Quality of life improvement, when you are a road-warrior, you are going + // to get an expired access token almost immediately, and a token refresh + // well before Wifi comes up, so allow retries while DNS errors are + // occurring, within reason. + var provider *oidc.Provider - tokens, err := a.Issue(r.Context(), info) + //nolint:contextcheck + callback := func() error { + t, err := newOIDCProvider(r.Context(), providerResource) if err != nil { - return nil, err + return err } - result := &openapi.Token{ - TokenType: "Bearer", - AccessToken: tokens.AccessToken, - RefreshToken: tokens.RefreshToken, - ExpiresIn: int(time.Until(tokens.Expiry).Seconds()), - } + provider = t - return result, nil + return nil } - if err := tokenValidate(r); err != nil { - return nil, err - } + retryContext, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() - code := &Code{} + if err := retry.Forever().DoWithContext(retryContext, callback); err != nil { + return nil, errors.OAuth2ServerError("failed to perform provider discovery").WithError(err) + } - if err := a.issuer.DecodeJWEToken(r.Context(), r.Form.Get("code"), code, jose.TokenTypeAuthorizationCode); err != nil { - return nil, errors.OAuth2InvalidRequest("failed to parse code: " + err.Error()) + refreshToken := &oauth2.Token{ + Expiry: time.Now(), + RefreshToken: claims.Custom.RefreshToken, } - if err := tokenValidateCode(code, r); err != nil { + providerTokens, err := a.oidcConfig(r, providerResource, provider.Endpoint(), nil).TokenSource(r.Context(), refreshToken).Token() + if err != nil { + var rerr *oauth2.RetrieveError + + if goerrors.As(err, &rerr) && rerr.ErrorCode == string(coreopenapi.InvalidGrant) { + return nil, errors.OAuth2InvalidGrant("provider refresh token has expired").WithError(err) + } + return nil, err } info := &IssueInfo{ Issuer: "https://" + r.Host, Audience: r.Host, - Subject: code.IDToken.OIDCClaimsEmail.Email, - Provider: code.OAuth2Provider, - Tokens: Tokens{ - Expiry: code.AccessTokenExpiry, - AccessToken: code.AccessToken, - RefreshToken: code.RefreshToken, + Subject: claims.Claims.Subject, + Tokens: &Tokens{ + Provider: claims.Custom.Provider, + Expiry: providerTokens.Expiry, + AccessToken: providerTokens.AccessToken, }, } - tokens, err := a.Issue(r.Context(), info) - if err != nil { - return nil, err + if providerTokens.RefreshToken != "" { + info.Tokens.RefreshToken = &providerTokens.RefreshToken } - // Handle OIDC. - idToken, err := a.oidcIDToken(r, code, a.options.AccessTokenDuration, oidcHash(tokens.AccessToken)) + tokens, err := a.Issue(r.Context(), info) if err != nil { return nil, err } @@ -1074,17 +1077,77 @@ func (a *Authenticator) Token(w http.ResponseWriter, r *http.Request) (*openapi. TokenType: "Bearer", AccessToken: tokens.AccessToken, RefreshToken: tokens.RefreshToken, - IdToken: idToken, ExpiresIn: int(time.Until(tokens.Expiry).Seconds()), } return result, nil } +// TokenClientCredentials issues a token if the client credentials are valid. We only support +// mTLS based authentication. +func (a *Authenticator) TokenClientCredentials(w http.ResponseWriter, r *http.Request) (*openapi.Token, error) { + certPEM, err := util.GetClientCertificateHeader(r.Header) + if err != nil { + return nil, errors.OAuth2InvalidRequest("mTLS client verification failed").WithError(err) + } + + certificate, err := util.GetClientCertificate(certPEM) + if err != nil { + return nil, errors.OAuth2InvalidRequest("mTLS certificate validation failed").WithError(err) + } + + thumbprint := util.GetClientCertiifcateThumbprint(certificate) + + info := &IssueInfo{ + Issuer: "https://" + r.Host, + Audience: r.Host, + Subject: certificate.Subject.CommonName, + X509Thumbprint: thumbprint, + } + + tokens, err := a.Issue(r.Context(), info) + if err != nil { + return nil, err + } + + result := &openapi.Token{ + TokenType: "Bearer", + AccessToken: tokens.AccessToken, + ExpiresIn: int(time.Until(tokens.Expiry).Seconds()), + } + + return result, nil +} + +// Token issues an OAuth2 access token from the provided authorization code. +func (a *Authenticator) Token(w http.ResponseWriter, r *http.Request) (*openapi.Token, error) { + if err := r.ParseForm(); err != nil { + return nil, errors.OAuth2InvalidRequest("failed to parse form data: " + err.Error()) + } + + // We sup"ort 3 garnt types: + // * "authorization_code" is used by all humans in the system + // * "refresh_token" is used by anyone to get a new access token + // * "client_credentials" is used by other services for IPC + switch openapi.GrantType(r.Form.Get("grant_type")) { + case openapi.AuthorizationCode: + return a.TokenAuthorizationCode(w, r) + case openapi.RefreshToken: + return a.TokenRefreshToken(w, r) + case openapi.ClientCredentials: + return a.TokenClientCredentials(w, r) + } + + return nil, errors.OAuth2InvalidRequest("token grant type is not supported") +} + func (a *Authenticator) Groups(w http.ResponseWriter, r *http.Request) (openapi.AvailableGroups, error) { - userinfo := userinfo.FromContext(r.Context()) + userinfo, err := authorization.UserinfoFromContext(r.Context()) + if err != nil { + return nil, errors.OAuth2ServerError("failed to get userinfo").WithError(err) + } - organization, err := a.lookupOrganization(r.Context(), userinfo.Subject) + organization, err := a.lookupOrganization(r.Context(), userinfo.Sub) if err != nil { if goerrors.Is(err, ErrUserNotDomainMapped) { // No domain mapped organization, no cry... diff --git a/pkg/oauth2/oauth2_test.go b/pkg/oauth2/oauth2_test.go index f8694c7c..dc2b7dce 100644 --- a/pkg/oauth2/oauth2_test.go +++ b/pkg/oauth2/oauth2_test.go @@ -67,13 +67,15 @@ func TestTokens(t *testing.T) { time.Sleep(2 * josetesting.RefreshPeriod) + refreshToken := "bar" + issueInfo := &oauth2.IssueInfo{ Issuer: "https://foo.com", Audience: "foo.com", Subject: "barry@foo.com", - Tokens: oauth2.Tokens{ + Tokens: &oauth2.Tokens{ AccessToken: "foo", - RefreshToken: "bar", + RefreshToken: &refreshToken, Expiry: time.Now().Add(2 * accessTokenDuration), }, } diff --git a/pkg/oauth2/tokens.go b/pkg/oauth2/tokens.go index 673c89f2..4ebf8c6f 100644 --- a/pkg/oauth2/tokens.go +++ b/pkg/oauth2/tokens.go @@ -53,10 +53,17 @@ type CustomAccessTokenClaims struct { type AccessTokenClaims struct { jwt.Claims `json:",inline"` + Config *AccessTokenConfigClaims `json:"cnf,omitempty"` + // Custom claims are application specific extensions. Custom *CustomAccessTokenClaims `json:"cat,omitempty"` } +type AccessTokenConfigClaims struct { + //nolint: tagliatelle + X509Thumbprint *string `json:"x5t@S256,omitempty"` +} + // CustomRefreshTokenClaims contains all application specific claims in a single // top-level claim that won't clash with the ones defined by IETF. type CustomRefreshTokenClaims struct { @@ -76,31 +83,36 @@ type RefreshTokenClaims struct { } type Tokens struct { + Provider string Expiry time.Time AccessToken string - RefreshToken string + RefreshToken *string } type IssueInfo struct { - Issuer string - Audience string - Subject string - Tokens Tokens - Provider string + Issuer string + Audience string + Subject string + Tokens *Tokens + X509Thumbprint string } // Issue issues a new JWT access token. func (a *Authenticator) Issue(ctx context.Context, info *IssueInfo) (*Tokens, error) { now := time.Now() - // We don't control the expiry of the provider's access token, but we can cap it, - // so we use the smallest of these two figures. To make the experience more - // resilient, we remove a "fudge factor" from the provider's token so we don't - // accidentally try to use it when it's already expired, e.g. time has expired - // since provider issue and when we wrap it up here. - expiry := info.Tokens.Expiry.Add(-a.options.TokenLeewayDuration) - if limit := now.Add(a.options.AccessTokenDuration); limit.Before(expiry) { - expiry = limit + expiry := now.Add(a.options.AccessTokenDuration) + + if info.Tokens != nil { + // We don't control the expiry of the provider's access token, but we can cap it, + // so we use the smallest of these two figures. To make the experience more + // resilient, we remove a "fudge factor" from the provider's token so we don't + // accidentally try to use it when it's already expired, e.g. time has expired + // since provider issue and when we wrap it up here. + expiry = info.Tokens.Expiry.Add(-a.options.TokenLeewayDuration) + if limit := now.Add(a.options.AccessTokenDuration); limit.Before(expiry) { + expiry = limit + } } nowRFC7519 := jwt.NewNumericDate(now) @@ -119,44 +131,60 @@ func (a *Authenticator) Issue(ctx context.Context, info *IssueInfo) (*Tokens, er NotBefore: nowRFC7519, Expiry: atExpiresAtRFC7519, }, - Custom: &CustomAccessTokenClaims{ - Provider: info.Provider, - AccessToken: info.Tokens.AccessToken, - }, } - at, err := a.issuer.EncodeJWEToken(ctx, atClaims, jose.TokenTypeAccessToken) - if err != nil { - return nil, err + // If we have a provider, then wrap up their access token so we can use + // it to access their APIs. + if info.Tokens != nil { + atClaims.Custom = &CustomAccessTokenClaims{ + Provider: info.Tokens.Provider, + AccessToken: info.Tokens.AccessToken, + } } - rtClaims := &RefreshTokenClaims{ - Claims: jwt.Claims{ - ID: uuid.New().String(), - Subject: info.Subject, - Audience: jwt.Audience{ - info.Audience, - }, - Issuer: info.Issuer, - IssuedAt: nowRFC7519, - NotBefore: nowRFC7519, - Expiry: rtExpiresAtRFC7519, - }, - Custom: &CustomRefreshTokenClaims{ - Provider: info.Provider, - RefreshToken: info.Tokens.RefreshToken, - }, + // An X509 thumbprint means we bind the token to the client certificate + // and only accept it when presented with the client cerficate also. + if info.X509Thumbprint != "" { + atClaims.Config = &AccessTokenConfigClaims{ + X509Thumbprint: &info.X509Thumbprint, + } } - rt, err := a.issuer.EncodeJWEToken(ctx, rtClaims, jose.TokenTypeRefreshToken) + at, err := a.issuer.EncodeJWEToken(ctx, atClaims, jose.TokenTypeAccessToken) if err != nil { return nil, err } tokens := &Tokens{ - AccessToken: at, - RefreshToken: rt, - Expiry: expiry, + AccessToken: at, + Expiry: expiry, + } + + if info.Tokens != nil && info.Tokens.RefreshToken != nil { + rtClaims := &RefreshTokenClaims{ + Claims: jwt.Claims{ + ID: uuid.New().String(), + Subject: info.Subject, + Audience: jwt.Audience{ + info.Audience, + }, + Issuer: info.Issuer, + IssuedAt: nowRFC7519, + NotBefore: nowRFC7519, + Expiry: rtExpiresAtRFC7519, + }, + Custom: &CustomRefreshTokenClaims{ + Provider: info.Tokens.Provider, + RefreshToken: *info.Tokens.RefreshToken, + }, + } + + rt, err := a.issuer.EncodeJWEToken(ctx, rtClaims, jose.TokenTypeRefreshToken) + if err != nil { + return nil, err + } + + tokens.RefreshToken = &rt } return tokens, nil diff --git a/pkg/openapi/schema.go b/pkg/openapi/schema.go index 3eb6d96f..f9cb3028 100644 --- a/pkg/openapi/schema.go +++ b/pkg/openapi/schema.go @@ -19,205 +19,205 @@ import ( // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "SNfCxELRAAA=", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/pkg/openapi/server.spec.yaml b/pkg/openapi/server.spec.yaml index 4d7a2474..e18d6902 100644 --- a/pkg/openapi/server.spec.yaml +++ b/pkg/openapi/server.spec.yaml @@ -681,11 +681,13 @@ components: enum: - client_secret_post - client_secret_basic + - tls_client_auth grantType: description: Supported grant type. type: string enum: - authorization_code + - client_credentials - refresh_token signingAlgorithm: description: Supported signing algorithms. @@ -780,7 +782,6 @@ components: required: - token_type - access_token - - refresh_token - expires_in properties: token_type: diff --git a/pkg/openapi/types.go b/pkg/openapi/types.go index 15230fda..6e0b6cac 100644 --- a/pkg/openapi/types.go +++ b/pkg/openapi/types.go @@ -23,6 +23,7 @@ const ( const ( ClientSecretBasic AuthMethod = "client_secret_basic" ClientSecretPost AuthMethod = "client_secret_post" + TlsClientAuth AuthMethod = "tls_client_auth" ) // Defines values for Claim. @@ -50,6 +51,7 @@ const ( // Defines values for GrantType. const ( AuthorizationCode GrantType = "authorization_code" + ClientCredentials GrantType = "client_credentials" RefreshToken GrantType = "refresh_token" ) @@ -419,7 +421,7 @@ type Token struct { IdToken *string `json:"id_token,omitempty"` // RefreshToken The opaque refresh token. - RefreshToken string `json:"refresh_token"` + RefreshToken *string `json:"refresh_token,omitempty"` // TokenType How the access token is to be presented to the resource server. TokenType string `json:"token_type"` diff --git a/pkg/provisioners/organization/provisioner.go b/pkg/provisioners/organization/provisioner.go index 58069194..03f308a1 100644 --- a/pkg/provisioners/organization/provisioner.go +++ b/pkg/provisioners/organization/provisioner.go @@ -22,6 +22,7 @@ import ( "errors" unikornv1core "github.com/unikorn-cloud/core/pkg/apis/unikorn/v1alpha1" + "github.com/unikorn-cloud/core/pkg/manager" "github.com/unikorn-cloud/core/pkg/provisioners" "github.com/unikorn-cloud/core/pkg/provisioners/resource" "github.com/unikorn-cloud/core/pkg/provisioners/util" @@ -44,7 +45,7 @@ type Provisioner struct { } // New returns a new initialized provisioner object. -func New() provisioners.ManagerProvisioner { +func New(_ manager.ControllerOptions) provisioners.ManagerProvisioner { return &Provisioner{} } diff --git a/pkg/provisioners/project/provisioner.go b/pkg/provisioners/project/provisioner.go index ac0943da..5b9fbad3 100644 --- a/pkg/provisioners/project/provisioner.go +++ b/pkg/provisioners/project/provisioner.go @@ -53,7 +53,7 @@ type Provisioner struct { } // New returns a new initialized provisioner object. -func New() provisioners.ManagerProvisioner { +func New(_ manager.ControllerOptions) provisioners.ManagerProvisioner { return &Provisioner{} } @@ -122,7 +122,10 @@ func (p *Provisioner) deprovisionDescendants(ctx context.Context, namespace *cor return err } - cli := coreclient.StaticClientFromContext(ctx) + cli, err := coreclient.ProvisionerClientFromContext(ctx) + if err != nil { + return err + } // If we found any resources, we need to await deletion. yield := false diff --git a/pkg/server/server.go b/pkg/server/server.go index 7a6c6e21..1e1ab574 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -24,20 +24,17 @@ import ( chi "github.com/go-chi/chi/v5" "github.com/spf13/pflag" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" - "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/trace" + "github.com/unikorn-cloud/core/pkg/manager/otel" coreapi "github.com/unikorn-cloud/core/pkg/openapi" - "github.com/unikorn-cloud/core/pkg/server/middleware/audit" "github.com/unikorn-cloud/core/pkg/server/middleware/cors" "github.com/unikorn-cloud/core/pkg/server/middleware/opentelemetry" "github.com/unikorn-cloud/core/pkg/server/middleware/timeout" - "github.com/unikorn-cloud/identity/pkg/authorization" "github.com/unikorn-cloud/identity/pkg/constants" "github.com/unikorn-cloud/identity/pkg/handler" "github.com/unikorn-cloud/identity/pkg/jose" + "github.com/unikorn-cloud/identity/pkg/middleware/audit" openapimiddleware "github.com/unikorn-cloud/identity/pkg/middleware/openapi" "github.com/unikorn-cloud/identity/pkg/middleware/openapi/local" "github.com/unikorn-cloud/identity/pkg/oauth2" @@ -69,6 +66,9 @@ type Server struct { // CORSOptions are for remote resource sharing. CORSOptions cors.Options + + // OTelOptions are for tracing. + OTelOptions otel.Options } func (s *Server) AddFlags(goflags *flag.FlagSet, flags *pflag.FlagSet) { @@ -79,6 +79,7 @@ func (s *Server) AddFlags(goflags *flag.FlagSet, flags *pflag.FlagSet) { s.JoseOptions.AddFlags(flags) s.OAuth2Options.AddFlags(flags) s.CORSOptions.AddFlags(flags) + s.OTelOptions.AddFlags(flags) } func (s *Server) SetupLogging() { @@ -88,34 +89,8 @@ func (s *Server) SetupLogging() { log.SetLogger(logr) } -// SetupOpenTelemetry adds a span processor that will print root spans to the -// logs by default, and optionally ship the spans to an OTLP listener. -// TODO: move config into an otel specific options struct. func (s *Server) SetupOpenTelemetry(ctx context.Context) error { - otel.SetLogger(log.Log) - - otel.SetTextMapPropagator(propagation.TraceContext{}) - - opts := []trace.TracerProviderOption{ - trace.WithSpanProcessor(&opentelemetry.LoggingSpanProcessor{}), - } - - if s.Options.OTLPEndpoint != "" { - exporter, err := otlptracehttp.New(ctx, - otlptracehttp.WithEndpoint(s.Options.OTLPEndpoint), - otlptracehttp.WithInsecure(), - ) - - if err != nil { - return err - } - - opts = append(opts, trace.WithBatcher(exporter)) - } - - otel.SetTracerProvider(trace.NewTracerProvider(opts...)) - - return nil + return s.OTelOptions.Setup(ctx, trace.WithSpanProcessor(&opentelemetry.LoggingSpanProcessor{})) } func (s *Server) GetServer(client client.Client) (*http.Server, error) { @@ -140,7 +115,6 @@ func (s *Server) GetServer(client client.Client) (*http.Server, error) { rbac := rbac.New(client, s.Options.Namespace) oauth2 := oauth2.New(&s.OAuth2Options, s.Options.Namespace, client, issuer, rbac) - authenticator := authorization.NewAuthenticator(issuer, oauth2) // Setup middleware. authorizer := local.NewAuthorizer(oauth2, rbac) @@ -156,7 +130,7 @@ func (s *Server) GetServer(client client.Client) (*http.Server, error) { }, } - handlerInterface, err := handler.New(client, s.Options.Namespace, authenticator, rbac, &s.HandlerOptions) + handlerInterface, err := handler.New(client, s.Options.Namespace, issuer, oauth2, rbac, &s.HandlerOptions) if err != nil { return nil, err } diff --git a/pkg/util/tls.go b/pkg/util/tls.go new file mode 100644 index 00000000..3ac78844 --- /dev/null +++ b/pkg/util/tls.go @@ -0,0 +1,86 @@ +/* +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "net/http" + "net/url" +) + +var ( + ErrClientCertificateNotPresent = errors.New("client certificate not presented") + + ErrClientCertificateError = errors.New("client certificate error") +) + +// GetClientCertificateHeader extracts a client certificate from any present headers. +// TODO: may need to extract into a canonical form. +// NOTE: propagation at present expects this to be url encoded. +func GetClientCertificateHeader(header http.Header) (string, error) { + // Nginx + if cert := header.Get("Ssl-Client-Cert"); cert != "" { + if header.Get("Ssl-Client-Verify") != "SUCCESS" { + return "", fmt.Errorf("%w: client certificate verification header error", ErrClientCertificateError) + } + + return cert, nil + } + + return "", ErrClientCertificateNotPresent +} + +// GetClientCertificate retrieves the client certificate from headers injected by +// the ingress controller. +func GetClientCertificate(in string) (*x509.Certificate, error) { + // The certificate is escaped, so undo that, then get the base64 encoded SHA256 of + // the certificate DER information, and we will use that as a binding of the token to + // the client certificate. We'll use that later for authentication... + certPEM, err := url.QueryUnescape(in) + if err != nil { + return nil, fmt.Errorf("%w: client certificate unescape failed", ErrClientCertificateError) + } + + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + return nil, fmt.Errorf("%w: client certificate not PEM encoded", ErrClientCertificateError) + } + + if block.Type != "CERTIFICATE" { + return nil, fmt.Errorf("%w: client certificate PEM encoding is not CERTIFICATE", ErrClientCertificateError) + } + + certificate, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("%w: client certificate parse failed", ErrClientCertificateError) + } + + return certificate, nil +} + +// GetClientCertiifcateThumbprint returns the client certificate thumbprint as defined +// by RFC8705. +func GetClientCertiifcateThumbprint(certificate *x509.Certificate) string { + sum := sha256.Sum256(certificate.Raw) + + return base64.URLEncoding.EncodeToString(sum[:]) +}