From 7739c9422689d567c11f88585030e66c1b614b71 Mon Sep 17 00:00:00 2001 From: Aayush Shah Date: Thu, 23 Jul 2020 03:14:32 -0400 Subject: [PATCH 1/2] server: remove safeServeMux `safeServeMux` was a wrapper to provide proper locking around `http.ServeMux` because in go1.10 and below, calls to `Handle` and `ServeHTTP` could race with each other. We don't need it anymore, see #29230. Release note: None --- pkg/server/servemux_test.go | 64 ------------------------------------- pkg/server/server.go | 24 +------------- 2 files changed, 1 insertion(+), 87 deletions(-) delete mode 100644 pkg/server/servemux_test.go diff --git a/pkg/server/servemux_test.go b/pkg/server/servemux_test.go deleted file mode 100644 index 7d800e87898f..000000000000 --- a/pkg/server/servemux_test.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright 2018 The Cockroach Authors. -// -// Use of this software is governed by the Business Source License -// included in the file licenses/BSL.txt. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the Apache License, Version 2.0, included in the file -// licenses/APL.txt. - -package server - -import ( - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "sync" - "testing" - "time" - - "github.com/cockroachdb/cockroach/pkg/util/leaktest" - "github.com/cockroachdb/cockroach/pkg/util/log" - "github.com/cockroachdb/cockroach/pkg/util/timeutil" -) - -func TestServeMuxConcurrency(t *testing.T) { - defer leaktest.AfterTest(t)() - defer log.Scope(t).Close(t) - - const duration = 20 * time.Millisecond - start := timeutil.Now() - - // TODO(peter): This test reliably fails using http.ServeMux with a - // "concurrent map read and write error" on go1.10. The bug in http.ServeMux - // is fixed in go1.11. - var mux safeServeMux - var wg sync.WaitGroup - wg.Add(2) - - go func() { - defer wg.Done() - f := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) - for i := 1; timeutil.Since(start) < duration; i++ { - mux.Handle(fmt.Sprintf("/%d", i), f) - } - }() - - go func() { - defer wg.Done() - for i := 1; timeutil.Since(start) < duration; i++ { - r := &http.Request{ - Method: "GET", - URL: &url.URL{ - Path: "/", - }, - } - w := httptest.NewRecorder() - mux.ServeHTTP(w, r) - } - }() - - wg.Wait() -} diff --git a/pkg/server/server.go b/pkg/server/server.go index 002ad9133ab9..fb6effd9190f 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -111,28 +111,6 @@ var ( ) ) -// TODO(peter): Until go1.11, ServeMux.ServeHTTP was not safe to call -// concurrently with ServeMux.Handle. So we provide our own wrapper with proper -// locking. Slightly less efficient because it locks unnecessarily, but -// safe. See TestServeMuxConcurrency. Should remove once we've upgraded to -// go1.11. -type safeServeMux struct { - mu syncutil.RWMutex - mux http.ServeMux -} - -func (mux *safeServeMux) Handle(pattern string, handler http.Handler) { - mux.mu.Lock() - mux.mux.Handle(pattern, handler) - mux.mu.Unlock() -} - -func (mux *safeServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) { - mux.mu.RLock() - mux.mux.ServeHTTP(w, r) - mux.mu.RUnlock() -} - // Server is the cockroach server node. type Server struct { // The following fields are populated in NewServer. @@ -140,7 +118,7 @@ type Server struct { nodeIDContainer *base.NodeIDContainer cfg Config st *cluster.Settings - mux safeServeMux + mux http.ServeMux clock *hlc.Clock rpcContext *rpc.Context // The gRPC server on which the different RPC handlers will be registered. From d6126f2a59ef3b9279810fe9e312bd65d961a1fe Mon Sep 17 00:00:00 2001 From: David Hartunian Date: Thu, 30 Jul 2020 20:32:41 -0400 Subject: [PATCH 2/2] auth: Add OIDC as a login option for Admin UI OIDC is a commonly used authentication layer built on top of authorization via OAuth 2.0. It allows CRDB to rely on an external identity provider to authenticate users into the Admin UI via a web browser. This feature is implemented by adding two new routes to the HTTP server and a collection of cluster settings that cover OIDC functionality. **Overview** This feature uses the `go-oidc` library to add the ability to: 1) redirect to a login provider and 2) process a callback from that provider with authenticated user data. To support the configuration of the auth provider, we've added a bunch of new cluster settings. Any modification of these settings reconfigures the oidc provider on a running cluster. The OIDC functionality can also be enabled/disabled using a cluster setting. **OIDC/OAuth Flow** The user flow we're supporting here is the following: 1. User navigates to the Admin UI on a secure cluster 2. User clicks on "Login with SSO" button instead of providing a username/password 3. User is redirected to their Auth provider where they either already have a session or can login with an account. 4. User successfully authenticates with 3rd party 5. User is redirected back to the CRDB cluster via a callback URL sent over in step 3 by CRDB. 6. CRDB checks user's info in the callback's query params and matches a field in the JWT token returned against the list of SQL users. 8. If there's a match, a web session is created for that user 7. User is redirected to the main Admin UI landing page where they see their cluster data **OAuth callback requires a load balancer URL** When redirecting a user to authenticate using an external provider, we must provide a "callback URL" for the auth provider to redirect the browser to once the login process is complete. This requires the CRDB operator to provide the oidc configuration with the cluster DNS and port where the cluster's HTTP server can be reached. The URL must then have the OIDC path appended to it so that the routing can properly work. For many deployments this will be a URL that points to a load balancer that can then proxy the HTTP connection to the cluster. **OAuth 2.0 State Variable** To prevent CSRF attacks, OAuth 2.0 uses a state variable that's transmitted on an auth request from the service provider. That same variable is passed back to the callback URL to "prove" that the request matches the one requested. This variable is set in a browser cookie at the time the `/login` page is loaded in the Admin UI and saved in a server-side cache. While processing the callback response we ensure that the state we receive matches the one in the cookie and on the server side. **Quality of Life UX Improvements** A button has been added to the Admin UI login screen to login via SSO. This button a simple link that redirects to the OIDC login path. Clicking initiates the login process. New fields have been added to the `dataFromServer` variable that is included in the Admin UI's index.html when it's rendered from the server. They are tied to cluster settings and control the OIDC user experience. First, `OIDCLoginEnabled` allows for toggling of the button, this is tied to the `server.oidc_authentication.enabled` cluster setting. Second, `OIDCButtonText` allows the operator to control the text on the button to display some deployment-specific call to action. This is tied to the `server.oidc_authentication.button_text` cluster setting. **Mapping OIDC principals to DB principals** All authenticated OIDC users must be mapped to a SQL user that already exists in CRDB. The way this is accomplished is through management of 3 cluster settings: 1. `server.oidc_authentication.scopes`: This string controls the scopes that are requested during authentication. These are standard OIDC parameters. Typically they have a value like "profile email". 2. `server.oidc_authentication.claim_json_key`: This string sets the name of the field on the JSON response that we will use to determine the user's SQL user in CRDB. Typically this a standard OIDC claim like "email" or "sid". 3. `server.oidc_authentication.principal_regex`: This string is a golang regular expression that controls the translation from OIDC principal (as extracted from the claim key set above) and the SQL user (created in CRDB). Typically OIDC authentication uses email-based identification so this feature allows you to provide a regular expression to strip out elements of the OIDC principal. For instance, to remove email addresses and just use the field to the left hand side of the `@` sign you could set this to: "^([^@]+)@[^@]+$". The code picks up the first grouping (defined by parentheses) in the regular expression. **How to try it out** To try out this funcitionality using Google auth, first create an OAuth client ID and secret at this URL: https://console.developers.google.com/apis/credentials Then configure the cluster with these settings: `server.oidc_authentication.client_id` = `server.oidc_authentication.client_secret` = `server.oidc_authentication.redirect_url` = "https://localhost:8080/oidc/v1/callback" `server.oidc_authentication.provider_url` = "https://accounts.google.com" `server.oidc_authentication.scopes` = "openid profile email" `server.oidc_authentication.claim_json_key` = "email" `server.oidc_authentication.enabled` = true `server.oidc_authentication.principal_regex` = "^([^@]+)@[^@]+$" Release justification: Customer commitment Release note (security update): Add ability to login via OIDC provider Operators can now configure a single OIDC authentication provider using a collection of cluster settings that can provide authentication for users into the Admin UI only (not for SQL!). The user must exist as a SQL user in the DB for the authentication to work. Release note (admin ui change): Add ability to login via OIDC provider --- docs/generated/settings/settings.html | 9 + go.mod | 3 + go.sum | 6 + pkg/ccl/ccl_init.go | 1 + pkg/ccl/oidcccl/authentication_oidc.go | 473 ++++++++++++++++ pkg/ccl/oidcccl/authentication_oidc_test.go | 246 +++++++++ pkg/ccl/oidcccl/settings.go | 166 ++++++ pkg/ccl/oidcccl/state.go | 79 +++ pkg/server/authentication.go | 102 +++- pkg/server/server.go | 10 + pkg/server/server_test.go | 6 +- pkg/server/serverpb/authentication.pb.go | 584 ++++++++++++++++++-- pkg/server/serverpb/authentication.proto | 44 +- pkg/ui/src/redux/login.ts | 10 + pkg/ui/src/util/dataFromServer.ts | 3 + pkg/ui/src/views/login/loginPage.styl | 8 +- pkg/ui/src/views/login/loginPage.tsx | 100 ++-- pkg/ui/ui.go | 26 + vendor | 2 +- 19 files changed, 1791 insertions(+), 87 deletions(-) create mode 100644 pkg/ccl/oidcccl/authentication_oidc.go create mode 100644 pkg/ccl/oidcccl/authentication_oidc_test.go create mode 100644 pkg/ccl/oidcccl/settings.go create mode 100644 pkg/ccl/oidcccl/state.go diff --git a/docs/generated/settings/settings.html b/docs/generated/settings/settings.html index 61179c116976..4910651745a6 100644 --- a/docs/generated/settings/settings.html +++ b/docs/generated/settings/settings.html @@ -38,6 +38,15 @@ server.consistency_check.max_ratebyte size8.0 MiBthe rate limit (bytes/sec) to use for consistency checks; used in conjunction with server.consistency_check.interval to control the frequency of consistency checks. Note that setting this too high can negatively impact performance. server.eventlog.ttlduration2160h0m0sif nonzero, event log entries older than this duration are deleted every 10m0s. Should not be lowered below 24 hours. server.host_based_authentication.configurationstringhost-based authentication configuration to use during connection authentication +server.oidc_authentication.button_textstringLogin with your OIDC providertext to show on button on admin ui login page to login with your OIDC provider (only shown if OIDC is enabled) (this feature is experimental) +server.oidc_authentication.claim_json_keystringsets JSON key of principal to extract from payload after OIDC authentication completes (usually email or sid) (this feature is experimental) +server.oidc_authentication.client_idstringsets OIDC client id (this feature is experimental) +server.oidc_authentication.client_secretstringsets OIDC client secret (this feature is experimental) +server.oidc_authentication.enabledbooleanfalseenables or disabled OIDC login for the Admin UI (this feature is experimental) +server.oidc_authentication.principal_regexstring(.+)regular expression to apply to extracted principal (see claim_json_key setting) to translate to SQL user (golang regex format, must include 1 grouping to extract) (this feature is experimental) +server.oidc_authentication.provider_urlstringsets OIDC provider URL ({provider_url}/.well-known/openid-configuration must resolve) (this feature is experimental) +server.oidc_authentication.redirect_urlstringhttps://localhost:8080/oidc/v1/callbacksets OIDC redirect URL (base HTTP URL, likely your load balancer, must route to the path /oidc/v1/callback) (this feature is experimental) +server.oidc_authentication.scopesstringopenidsets OIDC scopes to include with authentication request (space delimited list of strings, required to start with `openid`) (this feature is experimental) server.rangelog.ttlduration720h0m0sif nonzero, range log entries older than this duration are deleted every 10m0s. Should not be lowered below 24 hours. server.remote_debugging.modestringlocalset to enable remote debugging, localhost-only or disable (any, local, off) server.shutdown.drain_waitduration0sthe amount of time a server waits in an unready state before proceeding with the rest of the shutdown process diff --git a/go.mod b/go.mod index 5d0d5531736b..f456db0452ba 100644 --- a/go.mod +++ b/go.mod @@ -47,6 +47,7 @@ require ( github.com/cockroachdb/stress v0.0.0-20170808184505-29b5d31b4c3a github.com/cockroachdb/ttycolor v0.0.0-20180709150743-a1d5aaeb377d github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd + github.com/coreos/go-oidc v2.2.1+incompatible github.com/dave/dst v0.24.0 github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc // indirect github.com/docker/distribution v2.7.0+incompatible @@ -128,6 +129,7 @@ require ( github.com/pierrre/geohash v1.0.0 github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 github.com/pmezard/go-difflib v1.0.0 + github.com/pquerna/cachecontrol v0.0.0-20200819021114-67c6ae64274f // indirect github.com/prometheus/client_golang v1.1.0 github.com/prometheus/client_model v0.2.0 github.com/prometheus/common v0.9.1 @@ -161,6 +163,7 @@ require ( google.golang.org/grpc v1.29.1 gopkg.in/jcmturner/goidentity.v3 v3.0.0 // indirect gopkg.in/jcmturner/gokrb5.v7 v7.5.0 // indirect + gopkg.in/square/go-jose.v2 v2.5.1 // indirect gopkg.in/yaml.v2 v2.3.0 gotest.tools v2.2.0+incompatible // indirect honnef.co/go/tools v0.0.0-20190530104931-1f0868a609b7 diff --git a/go.sum b/go.sum index 17e4cda2eb92..07c2519632c2 100644 --- a/go.sum +++ b/go.sum @@ -190,6 +190,8 @@ github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcju github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk= +github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -631,6 +633,8 @@ github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6J github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/cachecontrol v0.0.0-20200819021114-67c6ae64274f h1:JDEmUDtyiLMyMlFwiaDOv2hxUp35497fkwePcLeV7j4= +github.com/pquerna/cachecontrol v0.0.0-20200819021114-67c6ae64274f/go.mod h1:hoLfEwdY11HjRfKFH6KqnPsfxlo3BP6bJehpDv8t6sQ= github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= @@ -954,6 +958,8 @@ gopkg.in/jcmturner/gokrb5.v7 v7.5.0/go.mod h1:l8VISx+WGYp+Fp7KRbsiUuXTTOnxIc3Tuv gopkg.in/jcmturner/rpc.v1 v1.1.0 h1:QHIUxTX1ISuAv9dD2wJ9HWQVuWDX/Zc0PfeC2tjc4rU= gopkg.in/jcmturner/rpc.v1 v1.1.0/go.mod h1:YIdkC4XfD6GXbzje11McwsDuOlZQSb9W4vfLvuNnlv8= gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= +gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= +gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/src-d/go-billy.v4 v4.3.0 h1:KtlZ4c1OWbIs4jCv5ZXrTqG8EQocr0g/d4DjNg70aek= gopkg.in/src-d/go-billy.v4 v4.3.0/go.mod h1:tm33zBoOwxjYHZIE+OV8bxTWFMJLrconzFMd38aARFk= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= diff --git a/pkg/ccl/ccl_init.go b/pkg/ccl/ccl_init.go index bdcd07caba02..108704fe49ae 100644 --- a/pkg/ccl/ccl_init.go +++ b/pkg/ccl/ccl_init.go @@ -21,6 +21,7 @@ import ( _ "github.com/cockroachdb/cockroach/pkg/ccl/gssapiccl" _ "github.com/cockroachdb/cockroach/pkg/ccl/importccl" _ "github.com/cockroachdb/cockroach/pkg/ccl/kvccl" + _ "github.com/cockroachdb/cockroach/pkg/ccl/oidcccl" _ "github.com/cockroachdb/cockroach/pkg/ccl/partitionccl" _ "github.com/cockroachdb/cockroach/pkg/ccl/storageccl" _ "github.com/cockroachdb/cockroach/pkg/ccl/storageccl/engineccl" diff --git a/pkg/ccl/oidcccl/authentication_oidc.go b/pkg/ccl/oidcccl/authentication_oidc.go new file mode 100644 index 000000000000..3a8d857619ce --- /dev/null +++ b/pkg/ccl/oidcccl/authentication_oidc.go @@ -0,0 +1,473 @@ +// Copyright 2020 The Cockroach Authors. +// +// Licensed as a CockroachDB Enterprise file under the Cockroach Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/cockroachdb/cockroach/blob/master/licenses/CCL.txt + +package oidcccl + +import ( + "context" + crypto_rand "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "regexp" + "strings" + + "github.com/cockroachdb/cockroach/pkg/ccl/utilccl" + "github.com/cockroachdb/cockroach/pkg/roachpb" + "github.com/cockroachdb/cockroach/pkg/rpc" + "github.com/cockroachdb/cockroach/pkg/rpc/nodedialer" + "github.com/cockroachdb/cockroach/pkg/server" + "github.com/cockroachdb/cockroach/pkg/server/serverpb" + "github.com/cockroachdb/cockroach/pkg/settings/cluster" + "github.com/cockroachdb/cockroach/pkg/sql" + "github.com/cockroachdb/cockroach/pkg/ui" + "github.com/cockroachdb/cockroach/pkg/util/log" + "github.com/cockroachdb/cockroach/pkg/util/syncutil" + "github.com/cockroachdb/cockroach/pkg/util/uuid" + "github.com/coreos/go-oidc" + "golang.org/x/oauth2" +) + +const ( + idTokenKey = "id_token" + codeKey = "code" + stateKey = "state" + stateCookieName = "oidc_state" + oidcLoginPath = "/oidc/v1/login" + oidcCallbackPath = "/oidc/v1/callback" + genericCallbackHTTPError = "OIDC: unable to complete authentication" + genericLoginHTTPError = "OIDC: unable to initiate authentication" +) + +// oidcAuthenticationServer is an implementation of the OpenID Connect authentication code flow +// to support single-sign-on to the Admin UI via an external identity provider. +// +// The implementation uses the `go-oidc` implementation and is supported through a number of +// cluster settings defined in `oidc/settings.go`. These configure the CRDB cluster to redirect +// to an auth provider for logins, and to accept a callback once authentication completes, where +// CRDB translates the provided login principal to a SQL principal and creates a web session. +// +// The implementation adds two new HTTP handlers to the server at +// `/oidc/v1/login/` and `/oidc/v1/callback` the functions of which are described below. +// +// A successful configuration and login flow looks like the following (logout logic is unchanged +// with OIDC): +// +// 0. The cluster operator configures the cluster to use OIDC. Once the cluster setting +// `server.oidc_authentication.enabled` is set to true, the OIDC client will make a request to +// retrieve the discovery document using the `server.oidc_authentication.provider_url` setting. +// This attempt will be retried automatically with every call to the `login` or `callback` or +// any change to any OIDC settings as long as `enabled` is still true. That behavior is meant to +// support easy recovery from any downtime or HTTP errors on the provider side. +// +// 1. A CRDB user opens the Admin UI and clicks on the `Login with OIDC` button (text is +// configurable using the `server.oidc_authentication.button_text` setting. +// +// 2. The browser loads `/oidc/v1/login` from the cluster, which triggers a redirect to the auth +// provider. A number of parameters are sent along with this request: (these are all defined in +// the OIDC spec available at: https://openid.net/specs/openid-connect-core-1_0.html) +// - client_id and client_secret: these are set using their correspondingly named cluster +// settings and are values that the auth provider will create. +// - redirect_uri: set using the `server.oidc_authentication.redirect_url` cluster setting. This +// will point to `/oidc/v1/callback` at the appropriate host or load balancer +// that the cluster is deployed to. +// - scopes: set using the `server.oidc_authentication.scopes` cluster setting. This defines what +// information about the user we expect to receive in the callback. +// - state: this is a base64 encoded protobuf value that contains the NodeID of the node that +// originated the login request and the state variable that was recorded as being in the +// caller's cookie at the time. This value wil be returned back as a parameter to the +// callback URL by the authentication provider. We check to make sure it matches the +// cookie and our stored state to ensure we're processing a response to the request +// that we triggered. +// +// 3. The user authenticates at the auth provider +// +// 4. The auth provider redirects to the `redirect_uri` we provided, which is handled at +// `/oidc/v1/callback`. We validate that the `state` parameter matches the user's browser cookie, +// then we exchange the `authentication_code` that was provided for an OAuth token from the +// auth provider via an HTTP request. This handled by the `go-oidc` library. Once we have the +// id token, we validate and decode it, extract a field from the JSON set via the +// `server.oidc_authentication.claim_json_key`. The key is then passed through a regular +// expression to transform its value to a DB principal (this is to support the typical workflow +// of stripping a realm or domain name from an email address principal). The regular expression +// is set using the `server.oidc_authentication.principal_regex` cluster setting. +// +// If the username we compute exists in the DB, we create a web session for them in the usual +// manner, bypassing any password validation requirements, and redirect them to `/` so they can +// enjoy a logged-in experience in the Admin UI. +type oidcAuthenticationServer struct { + mutex syncutil.RWMutex + conf oidcAuthenticationConf + oauth2Config oauth2.Config + verifier *oidc.IDTokenVerifier + stateValidator *stateValidator + // enabled is used to store whether the user has flipped the enabled flag in the cluster settings + // if enabled is true and initialized is false, the code will continue to attempt to re-initialize + // the OIDC server every time a handler is invoked for the login or callback endpoints. This is + // to help us gracefully recover from auth provider downtime without operator intervention. + enabled bool + initialized bool +} + +type oidcAuthenticationConf struct { + clientID string + clientSecret string + redirectURL string + providerURL string + scopes string + enabled bool + claimJSONKey string + principalRegex *regexp.Regexp + buttonText string +} + +// GetUIConf is used to extract certain parts of the OIDC +// configuration at run-time for embedding into the +// Admin UI HTML in order to manage the login experience +// the UI provides. +func (s *oidcAuthenticationServer) GetOIDCConf() ui.OIDCUIConf { + return ui.OIDCUIConf{ + ButtonText: s.conf.buttonText, + Enabled: s.enabled, + } +} + +func reloadConfig(ctx context.Context, server *oidcAuthenticationServer, st *cluster.Settings) { + server.mutex.Lock() + defer server.mutex.Unlock() + reloadConfigLocked(ctx, server, st) +} + +func reloadConfigLocked( + ctx context.Context, server *oidcAuthenticationServer, st *cluster.Settings, +) { + conf := oidcAuthenticationConf{ + clientID: OIDCClientID.Get(&st.SV), + clientSecret: OIDCClientSecret.Get(&st.SV), + redirectURL: OIDCRedirectURL.Get(&st.SV), + providerURL: OIDCProviderURL.Get(&st.SV), + scopes: OIDCScopes.Get(&st.SV), + claimJSONKey: OIDCClaimJSONKey.Get(&st.SV), + enabled: OIDCEnabled.Get(&st.SV), + // The success of this line is guaranteed by the validation of the setting + principalRegex: regexp.MustCompile(OIDCPrincipalRegex.Get(&st.SV)), + buttonText: OIDCButtonText.Get(&st.SV), + } + + server.initialized = false + server.conf = conf + server.stateValidator = newStateValidator() + if server.conf.enabled { + // `enabled` stores the configuration state and records the operator's _intent_ that the feature + // be enabled. Since the call to `NewProvider` below makes an HTTP request and could fail for + // many reasons, we record the successful configuration of a provider using the `initialized` + // flag which is set at the bottom of this function. + // If `enabled` is true and `initialized` is false, the HTTP handlers for OIDC will attempt + // to initialize the OIDC provider. + server.enabled = true + } else { + server.enabled = false + return + } + + provider, err := oidc.NewProvider(ctx, server.conf.providerURL) + if err != nil { + log.Warningf(ctx, "unable to initialize OIDC provider, disabling OIDC: %v", err) + return + } + + // Validation of the scope setting will require that we have the `openid` scope + scopesForOauth := strings.Split(server.conf.scopes, " ") + + server.oauth2Config = oauth2.Config{ + ClientID: server.conf.clientID, + ClientSecret: server.conf.clientSecret, + RedirectURL: server.conf.redirectURL, + + Endpoint: provider.Endpoint(), + Scopes: scopesForOauth, + } + + server.verifier = provider.Verifier(&oidc.Config{ClientID: server.conf.clientID}) + server.initialized = true + log.Infof(ctx, "initialized oidc server") +} + +// ConfigureOIDC attaches handlers to the server `mux` that +// can initiate and complete an OIDC authentication flow. +// This flow consists of an initial login request that triggers +// an HTTP redirect to the auth provider, and a callback endpoint +// that the auth provider redirects the user back to with +// parameters containing authenticated user info. +var ConfigureOIDC = func( + serverCtx context.Context, + st *cluster.Settings, + mux *http.ServeMux, + userLoginFromSSO func(ctx context.Context, username string) (*http.Cookie, error), + ambientCtx log.AmbientContext, + cluster uuid.UUID, + nodeDialer *nodedialer.Dialer, + nodeID roachpb.NodeID, +) (server.OIDC, error) { + oidcAuthentication := &oidcAuthenticationServer{} + + // Don't want to use GRPC here since these endpoints require HTTP-Redirect behaviors and the + // callback endpoint will be receiving specialized parameters that grpc-gateway will only get + // in the way of processing. + mux.HandleFunc(oidcCallbackPath, func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Verify state and errors. + oidcAuthentication.mutex.Lock() + defer oidcAuthentication.mutex.Unlock() + + if oidcAuthentication.enabled && !oidcAuthentication.initialized { + reloadConfigLocked(ctx, oidcAuthentication, st) + } + + if !oidcAuthentication.enabled { + http.Error(w, "OIDC: disabled", http.StatusBadRequest) + return + } + + state := r.URL.Query().Get(stateKey) + + // First we check to see that the state matches the cookie, then make sure it matches one we + // stored server-side. Since we stored the entire encoded proto in the cookie, we can compare + // without deserialization. + stateCookie, err := r.Cookie(stateCookieName) + if err != nil { + log.Errorf(ctx, "OIDC: missing client side cookie: %v", err) + http.Error(w, genericCallbackHTTPError, http.StatusInternalServerError) + return + } + if stateCookie.Value != state { + log.Errorf(ctx, "OIDC: client side cookie and callback param do not match: %v", err) + http.Error(w, genericCallbackHTTPError, http.StatusBadRequest) + return + } + + statePb, err := decodeOIDCState(state) + if err != nil { + log.Errorf(ctx, "OIDC: failed to decode state proto: %v", err) + http.Error(w, genericCallbackHTTPError, http.StatusInternalServerError) + return + } + + if statePb.NodeID == nodeID { + // Validate locally if same node. + err = oidcAuthentication.stateValidator.validateAndClear(string(statePb.Secret)) + if err != nil { + log.Errorf(ctx, "OIDC: this node reported invalid state: %v", err) + http.Error(w, genericCallbackHTTPError, http.StatusInternalServerError) + return + } + } else { + //Ask another node if necessary. + conn, err := nodeDialer.Dial(ctx, statePb.NodeID, rpc.DefaultClass) + if err != nil { + log.Errorf(ctx, "OIDC: failed to dial node %d to validate state: %v", statePb.NodeID, err) + http.Error(w, genericCallbackHTTPError, http.StatusInternalServerError) + return + } + client := serverpb.NewLogInClient(conn) + _, err = client.ValidateOIDCState(ctx, &serverpb.ValidateOIDCStateRequest{State: statePb}) + if err != nil { + log.Errorf(ctx, "OIDC: node %d reported invalid state: %v", statePb.NodeID, err) + http.Error(w, genericCallbackHTTPError, http.StatusInternalServerError) + return + } + } + + oauth2Token, err := oidcAuthentication.oauth2Config.Exchange(ctx, r.URL.Query().Get(codeKey)) + if err != nil { + log.Errorf(ctx, "OIDC: failed to exchange code for token: %v", err) + http.Error(w, genericCallbackHTTPError, http.StatusInternalServerError) + return + } + + rawIDToken, ok := oauth2Token.Extra(idTokenKey).(string) + if !ok { + log.Error(ctx, "OIDC: failed to extract ID token from OAuth2 token") + http.Error(w, genericCallbackHTTPError, http.StatusInternalServerError) + return + } + + idToken, err := oidcAuthentication.verifier.Verify(ctx, rawIDToken) + if err != nil { + log.Errorf(ctx, "OIDC: unable to verify token: %v", err) + http.Error(w, genericCallbackHTTPError, http.StatusInternalServerError) + return + } + + var claims map[string]json.RawMessage + if err := idToken.Claims(&claims); err != nil { + log.Errorf(ctx, "OIDC: unable to deserialize token claims: %v", err) + http.Error(w, genericCallbackHTTPError, http.StatusInternalServerError) + return + } + + var principal string + claim := claims[oidcAuthentication.conf.claimJSONKey] + if err := json.Unmarshal(claim, &principal); err != nil { + log.Errorf(ctx, "OIDC: failed to complete authentication: failed to extract claim key %s: %v", oidcAuthentication.conf.claimJSONKey, err) + http.Error(w, genericCallbackHTTPError, http.StatusInternalServerError) + return + } + + match := oidcAuthentication.conf.principalRegex.FindStringSubmatch(principal) + numGroups := len(match) + if numGroups != 2 { + log.Errorf(ctx, "OIDC: failed to complete authentication: expected one group in regexp, got %d", numGroups) + http.Error(w, genericCallbackHTTPError, http.StatusInternalServerError) + return + } + + username := match[1] + cookie, err := userLoginFromSSO(ctx, username) + if err != nil { + log.Errorf(ctx, "OIDC: failed to complete authentication: unable to create session for %s: %v", username, err) + http.Error(w, genericCallbackHTTPError, http.StatusForbidden) + return + } + + org := sql.ClusterOrganization.Get(&st.SV) + if err := utilccl.CheckEnterpriseEnabled(st, cluster, org, "OIDC"); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + http.SetCookie(w, cookie) + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + }) + + mux.HandleFunc(oidcLoginPath, func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + oidcAuthentication.mutex.Lock() + defer oidcAuthentication.mutex.Unlock() + + if oidcAuthentication.enabled && !oidcAuthentication.initialized { + reloadConfigLocked(ctx, oidcAuthentication, st) + } + + if !oidcAuthentication.enabled { + http.Error(w, "OIDC: disabled", http.StatusBadRequest) + return + } + + size := 16 + state := make([]byte, size) + if _, err := crypto_rand.Read(state); err != nil { + log.Errorf(ctx, "OIDC: unable to generate oidc state cookie: %v", err) + http.Error(w, genericLoginHTTPError, http.StatusInternalServerError) + return + } + base64State := base64.URLEncoding.EncodeToString(state) + oidcAuthentication.stateValidator.add(base64State) + + encodedStateProto, err := encodeOIDCState(serverpb.OIDCState{ + NodeID: nodeID, + Secret: []byte(base64State), + }) + if err != nil { + log.Errorf(ctx, "OIDC: no state encoded: %v", err) + http.Error(w, genericLoginHTTPError, http.StatusInternalServerError) + return + } + + oidcStateCookie := http.Cookie{ + Name: stateCookieName, + Value: encodedStateProto, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + } + + http.SetCookie(w, &oidcStateCookie) + http.Redirect(w, r, oidcAuthentication.oauth2Config.AuthCodeURL(encodedStateProto), http.StatusFound) + }) + + reloadConfig(serverCtx, oidcAuthentication, st) + + OIDCEnabled.SetOnChange(&st.SV, func() { + reloadConfig( + ambientCtx.AnnotateCtx(context.Background()), + oidcAuthentication, + st, + ) + }) + OIDCClientID.SetOnChange(&st.SV, func() { + reloadConfig( + ambientCtx.AnnotateCtx(context.Background()), + oidcAuthentication, + st, + ) + }) + OIDCClientSecret.SetOnChange(&st.SV, func() { + reloadConfig( + ambientCtx.AnnotateCtx(context.Background()), + oidcAuthentication, + st, + ) + }) + OIDCRedirectURL.SetOnChange(&st.SV, func() { + reloadConfig( + ambientCtx.AnnotateCtx(context.Background()), + oidcAuthentication, + st, + ) + }) + OIDCProviderURL.SetOnChange(&st.SV, func() { + reloadConfig( + ambientCtx.AnnotateCtx(context.Background()), + oidcAuthentication, + st, + ) + }) + OIDCScopes.SetOnChange(&st.SV, func() { + reloadConfig( + ambientCtx.AnnotateCtx(context.Background()), + oidcAuthentication, + st, + ) + }) + OIDCClaimJSONKey.SetOnChange(&st.SV, func() { + reloadConfig( + ambientCtx.AnnotateCtx(context.Background()), + oidcAuthentication, + st, + ) + }) + OIDCPrincipalRegex.SetOnChange(&st.SV, func() { + reloadConfig( + ambientCtx.AnnotateCtx(context.Background()), + oidcAuthentication, + st, + ) + }) + OIDCButtonText.SetOnChange(&st.SV, func() { + reloadConfig( + ambientCtx.AnnotateCtx(context.Background()), + oidcAuthentication, + st, + ) + }) + + return oidcAuthentication, nil +} + +func (s *oidcAuthenticationServer) ValidateOIDCState(state *serverpb.OIDCState) error { + s.mutex.Lock() + defer s.mutex.Unlock() + return s.stateValidator.validateAndClear(string(state.Secret)) +} + +func init() { + server.ConfigureOIDC = ConfigureOIDC +} diff --git a/pkg/ccl/oidcccl/authentication_oidc_test.go b/pkg/ccl/oidcccl/authentication_oidc_test.go new file mode 100644 index 000000000000..bf619f56a091 --- /dev/null +++ b/pkg/ccl/oidcccl/authentication_oidc_test.go @@ -0,0 +1,246 @@ +// Copyright 2020 The Cockroach Authors. +// +// Licensed as a CockroachDB Enterprise file under the Cockroach Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/cockroachdb/cockroach/blob/master/licenses/CCL.txt + +package oidcccl + +import ( + "context" + "fmt" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "os" + "testing" + + "github.com/cockroachdb/cockroach/pkg/base" + "github.com/cockroachdb/cockroach/pkg/ccl/utilccl" + "github.com/cockroachdb/cockroach/pkg/roachpb" + "github.com/cockroachdb/cockroach/pkg/rpc" + "github.com/cockroachdb/cockroach/pkg/security" + "github.com/cockroachdb/cockroach/pkg/security/securitytest" + "github.com/cockroachdb/cockroach/pkg/server" + "github.com/cockroachdb/cockroach/pkg/server/serverpb" + "github.com/cockroachdb/cockroach/pkg/testutils" + "github.com/cockroachdb/cockroach/pkg/testutils/serverutils" + "github.com/cockroachdb/cockroach/pkg/testutils/sqlutils" + "github.com/cockroachdb/cockroach/pkg/testutils/testcluster" + "github.com/cockroachdb/cockroach/pkg/util/hlc" + "github.com/cockroachdb/cockroach/pkg/util/leaktest" + "github.com/cockroachdb/cockroach/pkg/util/log" + "github.com/cockroachdb/cockroach/pkg/util/randutil" +) + +func TestMain(m *testing.M) { + defer utilccl.TestingEnableEnterprise()() + security.SetAssetLoader(securitytest.EmbeddedAssets) + randutil.SeedForTests() + serverutils.InitTestServerFactory(server.TestServerFactory) + serverutils.InitTestClusterFactory(testcluster.TestClusterFactory) + os.Exit(m.Run()) +} + +func TestOIDCBadRequestIfDisabled(t *testing.T) { + defer leaktest.AfterTest(t)() + defer log.Scope(t).Close(t) + + s, _, _ := serverutils.StartServer(t, base.TestServerArgs{}) + defer s.Stopper().Stop(context.Background()) + + newRPCContext := func(cfg *base.Config) *rpc.Context { + return rpc.NewContext(rpc.ContextOptions{ + TenantID: roachpb.SystemTenantID, + Config: cfg, + Clock: hlc.NewClock(hlc.UnixNano, 1), + Stopper: s.Stopper(), + Settings: s.ClusterSettings(), + }) + } + + plainHTTPCfg := testutils.NewTestBaseContext(server.TestUser) + testCertsContext := newRPCContext(plainHTTPCfg) + + client, err := testCertsContext.GetHTTPClient() + if err != nil { + t.Fatal(err) + } + + resp, err := client.Get(s.AdminURL() + "/oidc/v1/login") + if err != nil { + t.Fatalf("could not issue GET request to admin server: %s", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 400 { + t.Fatalf("expected 400 status code but got: %d", resp.StatusCode) + } +} + +func TestOIDCEnabled(t *testing.T) { + defer leaktest.AfterTest(t)() + defer log.Scope(t).Close(t) + + s, db, _ := serverutils.StartServer(t, base.TestServerArgs{}) + defer s.Stopper().Stop(context.Background()) + + newRPCContext := func(cfg *base.Config) *rpc.Context { + return rpc.NewContext(rpc.ContextOptions{ + TenantID: roachpb.SystemTenantID, + Config: cfg, + Clock: hlc.NewClock(hlc.UnixNano, 1), + Stopper: s.Stopper(), + Settings: s.ClusterSettings(), + }) + } + + // Set up a test OIDC server that serves the JSON discovery document + var issuer string + oidcHandler := func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/.well-known/openid-configuration" { + w.Header().Set("content-type", "application/json") + fakeDiscoveryDocument := ` +{ + "issuer": "` + issuer + `", + "authorization_endpoint": "https://accounts.cockroachlabs.com/o/oauth2/v2/auth", + "device_authorization_endpoint": "https://oauth2.cockroachlabsapis.com/device/code", + "token_endpoint": "https://oauth2.cockroachlabsapis.com/token", + "userinfo_endpoint": "https://openidconnect.cockroachlabsapis.com/v1/userinfo", + "revocation_endpoint": "https://oauth2.cockroachlabsapis.com/revoke", + "jwks_uri": "https://www.cockroachlabsapis.com/oauth2/v3/certs", + "response_types_supported": [ + "code", + "token", + "id_token", + "code token", + "code id_token", + "token id_token", + "code token id_token", + "none" + ], + "subject_types_supported": [ + "public" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "scopes_supported": [ + "openid", + "email", + "profile" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic" + ], + "claims_supported": [ + "aud", + "email", + "email_verified", + "exp", + "family_name", + "given_name", + "iat", + "iss", + "locale", + "name", + "picture", + "sub" + ], + "code_challenge_methods_supported": [ + "plain", + "S256" + ], + "grant_types_supported": [ + "authorization_code", + "refresh_token", + "urn:ietf:params:oauth:grant-type:device_code", + "urn:ietf:params:oauth:grant-type:jwt-bearer" + ] +}` + _, _ = fmt.Fprint(w, fakeDiscoveryDocument) + return + } + http.Error(w, "wrong path", http.StatusBadRequest) + } + testOIDCServer := httptest.NewServer(http.HandlerFunc(oidcHandler)) + defer testOIDCServer.Close() + issuer = testOIDCServer.URL + + // Set minimum settings to successfully enable the OIDC client + sqlDB := sqlutils.MakeSQLRunner(db) + sqlDB.Exec(t, `SET CLUSTER SETTING server.oidc_authentication.provider_url = "`+testOIDCServer.URL+`"`) + sqlDB.Exec(t, `SET CLUSTER SETTING server.oidc_authentication.client_id = "fake_client_id"`) + sqlDB.Exec(t, `SET CLUSTER SETTING server.oidc_authentication.client_secret = "fake_client_secret"`) + sqlDB.Exec(t, `SET CLUSTER SETTING server.oidc_authentication.redirect_url = "https://cockroachlabs.com/oidc/v1/callback"`) + sqlDB.Exec(t, `SET CLUSTER SETTING server.oidc_authentication.enabled = "true"`) + + plainHTTPCfg := testutils.NewTestBaseContext(server.TestUser) + testCertsContext := newRPCContext(plainHTTPCfg) + + client, err := testCertsContext.GetHTTPClient() + if err != nil { + t.Fatal(err) + } + + // Add `oidc_state` cookie to our client + cookies, err := cookiejar.New(nil) + if err != nil { + t.Fatalf("unable to create cookiejar: %v", err) + } + adminURL, err := url.Parse(s.AdminURL()) + if err != nil { + t.Fatalf("unable to parse admin url: %v", err) + } + cookies.SetCookies(adminURL, []*http.Cookie{{Name: "oidc_state", Value: "blahblah"}}) + client.Jar = cookies + + // Don't follow redirects so we can inspect the 302 + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + t.Run("login redirect", func(t *testing.T) { + resp, err := client.Get(s.AdminURL() + "/oidc/v1/login") + if err != nil { + t.Fatalf("could not issue GET request to admin server: %s", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 302 { + t.Fatalf("expected 302 status code but got: %d", resp.StatusCode) + } + authURL, err := url.Parse(resp.Header.Get("Location")) + if err != nil { + t.Fatal(err) + } + if authURL.Query().Get("client_id") != "fake_client_id" { + t.Fatal("expected fake client_id", authURL) + } + const expectedRedirectURL = "https://cockroachlabs.com/oidc/v1/callback" + if authURL.Query().Get("redirect_uri") != expectedRedirectURL { + t.Fatal("expected fake redirect_url", authURL) + } + }) +} + +func TestOIDCStateEncodeDecode(t *testing.T) { + testString := "abc-123-@~~" // This string produces discrepancy when base46 URL is used vs Std + encoded, err := encodeOIDCState(serverpb.OIDCState{Secret: []byte(testString), NodeID: 3}) + if err != nil { + t.Fatal(err) + } + + state, err := decodeOIDCState(encoded) + if err != nil { + t.Fatal(err) + } + + if string(state.Secret) != testString || state.NodeID != 3 { + t.Fatal("state didn't match when decoded") + } +} diff --git a/pkg/ccl/oidcccl/settings.go b/pkg/ccl/oidcccl/settings.go new file mode 100644 index 000000000000..c0195505f324 --- /dev/null +++ b/pkg/ccl/oidcccl/settings.go @@ -0,0 +1,166 @@ +// Copyright 2020 The Cockroach Authors. +// +// Licensed as a CockroachDB Enterprise file under the Cockroach Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/cockroachdb/cockroach/blob/master/licenses/CCL.txt + +package oidcccl + +import ( + "net/url" + "regexp" + "strings" + + "github.com/cockroachdb/cockroach/pkg/settings" + "github.com/cockroachdb/errors" + "github.com/coreos/go-oidc" +) + +// All cluster settings necessary for the OIDC feature. +const ( + baseOIDCSettingName = "server.oidc_authentication." + OIDCEnabledSettingName = baseOIDCSettingName + "enabled" + OIDCClientIDSettingName = baseOIDCSettingName + "client_id" + OIDCClientSecretSettingName = baseOIDCSettingName + "client_secret" + OIDCRedirectURLSettingName = baseOIDCSettingName + "redirect_url" + OIDCProviderURLSettingName = baseOIDCSettingName + "provider_url" + OIDCScopesSettingName = baseOIDCSettingName + "scopes" + OIDCClaimJSONKeySettingName = baseOIDCSettingName + "claim_json_key" + OIDCPrincipalRegexSettingName = baseOIDCSettingName + "principal_regex" + OIDCButtonTextSettingName = baseOIDCSettingName + "button_text" +) + +// OIDCEnabled enables or disabled OIDC login for the Admin UI +var OIDCEnabled = func() *settings.BoolSetting { + s := settings.RegisterPublicBoolSetting( + OIDCEnabledSettingName, + "enables or disabled OIDC login for the Admin UI (this feature is experimental)", + false, + ) + s.SetReportable(true) + return s +}() + +// OIDCClientID is the OIDC client id +var OIDCClientID = func() *settings.StringSetting { + s := settings.RegisterPublicStringSetting( + OIDCClientIDSettingName, + "sets OIDC client id (this feature is experimental)", + "", + ) + s.SetReportable(true) + return s +}() + +// OIDCClientSecret is the OIDC client secret +var OIDCClientSecret = func() *settings.StringSetting { + s := settings.RegisterPublicStringSetting( + OIDCClientSecretSettingName, + "sets OIDC client secret (this feature is experimental)", + "", + ) + s.SetReportable(false) + return s +}() + +// OIDCRedirectURL is the cluster URL to redirect to after OIDC auth completes +var OIDCRedirectURL = func() *settings.StringSetting { + s := settings.RegisterValidatedStringSetting( + OIDCRedirectURLSettingName, + "sets OIDC redirect URL (base HTTP URL, likely your load balancer, must route to the path /oidc/v1/callback) (this feature is experimental)", + "https://localhost:8080/oidc/v1/callback", + func(values *settings.Values, s string) error { + _, err := url.Parse(s) + if err != nil { + return err + } + return nil + }, + ) + s.SetReportable(true) + s.SetVisibility(settings.Public) + return s +}() + +// OIDCProviderURL is the location of the OIDC discovery document for the auth provider +var OIDCProviderURL = func() *settings.StringSetting { + s := settings.RegisterValidatedStringSetting( + OIDCProviderURLSettingName, + "sets OIDC provider URL ({provider_url}/.well-known/openid-configuration must resolve) (this feature is experimental)", + "", + func(values *settings.Values, s string) error { + _, err := url.Parse(s) + if err != nil { + return err + } + return nil + }, + ) + s.SetReportable(true) + s.SetVisibility(settings.Public) + return s +}() + +// OIDCScopes contains the list of scopes to request from the auth provider +var OIDCScopes = func() *settings.StringSetting { + s := settings.RegisterValidatedStringSetting( + OIDCScopesSettingName, + "sets OIDC scopes to include with authentication request "+ + "(space delimited list of strings, required to start with `openid`) (this feature is experimental)", + "openid", + func(values *settings.Values, s string) error { + if s != oidc.ScopeOpenID && !strings.HasPrefix(s, oidc.ScopeOpenID+" ") { + return errors.New("Missing `openid` scope which is required for OIDC") + } + return nil + }, + ) + s.SetReportable(true) + s.SetVisibility(settings.Public) + return s +}() + +// OIDCClaimJSONKey is the key of the claim to extract from the OIDC id_token +var OIDCClaimJSONKey = func() *settings.StringSetting { + s := settings.RegisterPublicStringSetting( + OIDCClaimJSONKeySettingName, + "sets JSON key of principal to extract from payload after OIDC authentication completes "+ + "(usually email or sid) (this feature is experimental)", + "", + ) + return s +}() + +// OIDCPrincipalRegex is a regular expression to apply to the OIDC id_token claim value to conver +// it to a DB principal +var OIDCPrincipalRegex = func() *settings.StringSetting { + s := settings.RegisterValidatedStringSetting( + OIDCPrincipalRegexSettingName, + "regular expression to apply to extracted principal (see claim_json_key setting) to "+ + "translate to SQL user (golang regex format, must include 1 grouping to extract) (this feature is experimental)", + "(.+)", + func(values *settings.Values, s string) error { + _, err := regexp.Compile(s) + if err != nil { + return errors.Wrapf(err, "unable to initialize %s setting, regex does not compile", + OIDCPrincipalRegexSettingName) + } + return nil + }, + ) + s.SetVisibility(settings.Public) + return s +}() + +// OIDCButtonText is a string to display on the button in the Admin UI to login with OIDC +var OIDCButtonText = func() *settings.StringSetting { + s := settings.RegisterPublicStringSetting( + OIDCButtonTextSettingName, + "text to show on button on admin ui login page to login with your OIDC provider "+ + "(only shown if OIDC is enabled) (this feature is experimental)", + "Login with your OIDC provider", + ) + return s +}() diff --git a/pkg/ccl/oidcccl/state.go b/pkg/ccl/oidcccl/state.go new file mode 100644 index 000000000000..d8e8f9a36d27 --- /dev/null +++ b/pkg/ccl/oidcccl/state.go @@ -0,0 +1,79 @@ +// Copyright 2020 The Cockroach Authors. +// +// Licensed as a CockroachDB Enterprise file under the Cockroach Community +// License (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// https://github.com/cockroachdb/cockroach/blob/master/licenses/CCL.txt + +package oidcccl + +import ( + "encoding/base64" + + "github.com/cockroachdb/cockroach/pkg/server/serverpb" + "github.com/cockroachdb/cockroach/pkg/util/cache" + "github.com/cockroachdb/cockroach/pkg/util/protoutil" + "github.com/cockroachdb/cockroach/pkg/util/timeutil" + "github.com/cockroachdb/errors" +) + +// stateValidator will be embedded in the OIDC server and concurrent access will be managed by the +// mutex in there. +type stateValidator struct { + states *cache.UnorderedCache +} + +// Hold elements in state cache with max TTL of an hour or 5000 elements. This helps ensure that +// old state variables get cleaned out if OAuth never succeeds, and that the cache doesn't grow +// past a certain size and cause storage problems on a node. +// Successfully "used" state variables are cleared out as soon as the OAuth callback is triggered +// so the storage would only grow with "bad" login attempts. +const size = 5000 +const maxTTLSeconds = 60 * 60 + +func newStateValidator() *stateValidator { + return &stateValidator{ + states: cache.NewUnorderedCache(cache.Config{ + Policy: cache.CacheLRU, + ShouldEvict: func(s int, key, value interface{}) bool { + return timeutil.Now().Unix()-value.(int64) > maxTTLSeconds || s > size + }, + }), + } +} + +func (s *stateValidator) add(state string) { + s.states.Add(state, timeutil.Now().UnixNano()) +} + +// validateAndClear will check that the given state is in our cache and if so, will also remove it +// this ensures that every state is "consumed" once as part of validation and can't be reused. +func (s *stateValidator) validateAndClear(state string) error { + if _, ok := s.states.Get(state); !ok { + return errors.New("state validator: unknown state") + } + s.states.Del(state) + return nil +} + +func encodeOIDCState(statePb serverpb.OIDCState) (string, error) { + stateBytes, err := protoutil.Marshal(&statePb) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(stateBytes), nil +} + +func decodeOIDCState(encodedState string) (*serverpb.OIDCState, error) { + // Cookie value should be a base64 encoded protobuf. + stateBytes, err := base64.URLEncoding.DecodeString(encodedState) + if err != nil { + return nil, errors.Wrap(err, "state could not be decoded") + } + var stateValue serverpb.OIDCState + if err := protoutil.Unmarshal(stateBytes, &stateValue); err != nil { + return nil, errors.Wrap(err, "state could not be unmarshaled") + } + return &stateValue, nil +} diff --git a/pkg/server/authentication.go b/pkg/server/authentication.go index ae920d9f5a6a..56ac33f481a6 100644 --- a/pkg/server/authentication.go +++ b/pkg/server/authentication.go @@ -21,16 +21,21 @@ import ( "strconv" "time" + "github.com/cockroachdb/cockroach/pkg/roachpb" + "github.com/cockroachdb/cockroach/pkg/rpc/nodedialer" "github.com/cockroachdb/cockroach/pkg/security" "github.com/cockroachdb/cockroach/pkg/server/serverpb" "github.com/cockroachdb/cockroach/pkg/settings" + "github.com/cockroachdb/cockroach/pkg/settings/cluster" "github.com/cockroachdb/cockroach/pkg/sql" "github.com/cockroachdb/cockroach/pkg/sql/sem/tree" "github.com/cockroachdb/cockroach/pkg/sql/sessiondata" "github.com/cockroachdb/cockroach/pkg/sql/types" + "github.com/cockroachdb/cockroach/pkg/ui" "github.com/cockroachdb/cockroach/pkg/util/log" "github.com/cockroachdb/cockroach/pkg/util/protoutil" "github.com/cockroachdb/cockroach/pkg/util/timeutil" + "github.com/cockroachdb/cockroach/pkg/util/uuid" "github.com/cockroachdb/errors" gwruntime "github.com/grpc-ecosystem/grpc-gateway/runtime" "google.golang.org/grpc" @@ -50,6 +55,40 @@ const ( SessionCookieName = "session" ) +type noOIDCConfigured struct{} + +func (c *noOIDCConfigured) GetOIDCConf() ui.OIDCUIConf { + return ui.OIDCUIConf{ + Enabled: false, + } +} + +func (c *noOIDCConfigured) ValidateOIDCState(state *serverpb.OIDCState) error { + return errors.New("OIDC is not enabled") +} + +// OIDC is an interface that an OIDC-based authentication module should implement to integrate with +// the rest of the node's functionality +type OIDC interface { + ui.OIDCUI + ValidateOIDCState(state *serverpb.OIDCState) error +} + +// ConfigureOIDC is a hook for the `oidcccl` library to add OIDC login support. It's called during +// server startup to initialize a client for OIDC support. +var ConfigureOIDC = func( + ctx context.Context, + st *cluster.Settings, + mux *http.ServeMux, + userLoginFromSSO func(ctx context.Context, username string) (*http.Cookie, error), + ambientCtx log.AmbientContext, + cluster uuid.UUID, + nodeDialer *nodedialer.Dialer, + nodeID roachpb.NodeID, +) (OIDC, error) { + return &noOIDCConfigured{}, nil +} + var webSessionTimeout = settings.RegisterPublicNonNegativeDurationSetting( "server.web_session_timeout", "the duration that a newly created web session will be valid", @@ -120,6 +159,57 @@ func (s *authenticationServer) UserLogin( ) } + cookie, err := s.createSessionFor(ctx, username) + if err != nil { + return nil, apiInternalError(ctx, err) + } + + // Set the cookie header on the outgoing response. + if err := grpc.SetHeader(ctx, metadata.Pairs("set-cookie", cookie.String())); err != nil { + return nil, apiInternalError(ctx, err) + } + + return &serverpb.UserLoginResponse{}, nil +} + +var errUsernameDoesNotExist = errors.New("username for session does not exist") + +func (s *authenticationServer) ValidateOIDCState( + ctx context.Context, req *serverpb.ValidateOIDCStateRequest, +) (*serverpb.ValidateOIDCStateResponse, error) { + err := s.server.oidc.ValidateOIDCState(req.State) + if err != nil { + return nil, err + } + + return &serverpb.ValidateOIDCStateResponse{}, nil +} + +// UserLoginFromSSO checks for the existence of a given username and if it exists, +// creates a session for the username in the `web_sessions` table. +// The session's ID and secret are returned to the caller as an HTTP cookie, +// added via a "Set-Cookie" header. +func (s *authenticationServer) UserLoginFromSSO( + ctx context.Context, username string, +) (*http.Cookie, error) { + exists, _, _, _, err := sql.GetUserHashedPassword( + ctx, s.server.sqlServer.execCfg.InternalExecutor, username, + ) + + if err != nil { + return nil, errors.Wrap(err, "failed creating session for username") + } + + if !exists { + return nil, errUsernameDoesNotExist + } + + return s.createSessionFor(ctx, username) +} + +func (s *authenticationServer) createSessionFor( + ctx context.Context, username string, +) (*http.Cookie, error) { // Create a new database session, generating an ID and secret key. id, secret, err := s.newAuthSession(ctx, username) if err != nil { @@ -133,17 +223,7 @@ func (s *authenticationServer) UserLogin( ID: id, Secret: secret, } - cookie, err := EncodeSessionCookie(cookieValue, !s.server.cfg.DisableTLSForHTTP) - if err != nil { - return nil, apiInternalError(ctx, err) - } - - // Set the cookie header on the outgoing response. - if err := grpc.SetHeader(ctx, metadata.Pairs("set-cookie", cookie.String())); err != nil { - return nil, apiInternalError(ctx, err) - } - - return &serverpb.UserLoginResponse{}, nil + return EncodeSessionCookie(cookieValue, !s.server.cfg.DisableTLSForHTTP) } // UserLogout allows a user to terminate their currently active session. diff --git a/pkg/server/server.go b/pkg/server/server.go index fb6effd9190f..ca1260a2a6b4 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -138,6 +138,7 @@ type Server struct { admin *adminServer status *statusServer authentication *authenticationServer + oidc OIDC tsDB *ts.DB tsServer *ts.Server raftTransport *kvserver.RaftTransport @@ -1569,6 +1570,14 @@ func (s *Server) Start(ctx context.Context) error { // something associated to SQL tenants. s.startSystemLogsGC(ctx) + // OIDC Configuration must happen prior to the UI Handler being defined below so that we have + // the system settings initialized for it to pick up from the oidcAuthenticationServer. + oidc, err := ConfigureOIDC(ctx, s.ClusterSettings(), &s.mux, s.authentication.UserLoginFromSSO, s.cfg.AmbientCtx, s.ClusterID(), s.nodeDialer, s.NodeID()) + if err != nil { + return err + } + s.oidc = oidc + // Serve UI assets. // // The authentication mux used here is created in "allow anonymous" mode so that the UI @@ -1581,6 +1590,7 @@ func (s *Server) Start(ctx context.Context) error { ExperimentalUseLogin: s.cfg.EnableWebSessionAuthentication, LoginEnabled: s.cfg.RequireWebSession(), NodeID: s.nodeIDContainer, + OIDC: oidc, GetUser: func(ctx context.Context) *string { if u, ok := ctx.Value(webSessionUserKey{}).(string); ok { return &u diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index aca521b1e866..9ccd623a3472 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -1041,7 +1041,7 @@ Binary built without web UI. expected := fmt.Sprintf( htmlTemplate, fmt.Sprintf( - `{"ExperimentalUseLogin":false,"LoginEnabled":false,"LoggedInUser":null,"Tag":"%s","Version":"%s","NodeID":"%d"}`, + `{"ExperimentalUseLogin":false,"LoginEnabled":false,"LoggedInUser":null,"Tag":"%s","Version":"%s","NodeID":"%d","PasswordLoginEnabled":true,"OIDCLoginEnabled":false,"OIDCButtonText":""}`, build.GetInfo().Tag, build.VersionPrefix(), 1, @@ -1076,7 +1076,7 @@ Binary built without web UI. { loggedInClient, fmt.Sprintf( - `{"ExperimentalUseLogin":true,"LoginEnabled":true,"LoggedInUser":"authentic_user","Tag":"%s","Version":"%s","NodeID":"%d"}`, + `{"ExperimentalUseLogin":true,"LoginEnabled":true,"LoggedInUser":"authentic_user","Tag":"%s","Version":"%s","NodeID":"%d","PasswordLoginEnabled":true,"OIDCLoginEnabled":false,"OIDCButtonText":""}`, build.GetInfo().Tag, build.VersionPrefix(), 1, @@ -1085,7 +1085,7 @@ Binary built without web UI. { loggedOutClient, fmt.Sprintf( - `{"ExperimentalUseLogin":true,"LoginEnabled":true,"LoggedInUser":null,"Tag":"%s","Version":"%s","NodeID":"%d"}`, + `{"ExperimentalUseLogin":true,"LoginEnabled":true,"LoggedInUser":null,"Tag":"%s","Version":"%s","NodeID":"%d","PasswordLoginEnabled":true,"OIDCLoginEnabled":false,"OIDCButtonText":""}`, build.GetInfo().Tag, build.VersionPrefix(), 1, diff --git a/pkg/server/serverpb/authentication.pb.go b/pkg/server/serverpb/authentication.pb.go index 6be7ea6c5b36..07627958f021 100644 --- a/pkg/server/serverpb/authentication.pb.go +++ b/pkg/server/serverpb/authentication.pb.go @@ -7,6 +7,8 @@ import proto "github.com/gogo/protobuf/proto" import fmt "fmt" import math "math" +import github_com_cockroachdb_cockroach_pkg_roachpb "github.com/cockroachdb/cockroach/pkg/roachpb" + import ( context "context" grpc "google.golang.org/grpc" @@ -37,7 +39,7 @@ func (m *UserLoginRequest) Reset() { *m = UserLoginRequest{} } func (m *UserLoginRequest) String() string { return proto.CompactTextString(m) } func (*UserLoginRequest) ProtoMessage() {} func (*UserLoginRequest) Descriptor() ([]byte, []int) { - return fileDescriptor_authentication_38fb587d3f7ced6d, []int{0} + return fileDescriptor_authentication_60086026974687e4, []int{0} } func (m *UserLoginRequest) XXX_Unmarshal(b []byte) error { return m.Unmarshal(b) @@ -72,7 +74,7 @@ func (m *UserLoginResponse) Reset() { *m = UserLoginResponse{} } func (m *UserLoginResponse) String() string { return proto.CompactTextString(m) } func (*UserLoginResponse) ProtoMessage() {} func (*UserLoginResponse) Descriptor() ([]byte, []int) { - return fileDescriptor_authentication_38fb587d3f7ced6d, []int{1} + return fileDescriptor_authentication_60086026974687e4, []int{1} } func (m *UserLoginResponse) XXX_Unmarshal(b []byte) error { return m.Unmarshal(b) @@ -107,7 +109,7 @@ func (m *UserLogoutRequest) Reset() { *m = UserLogoutRequest{} } func (m *UserLogoutRequest) String() string { return proto.CompactTextString(m) } func (*UserLogoutRequest) ProtoMessage() {} func (*UserLogoutRequest) Descriptor() ([]byte, []int) { - return fileDescriptor_authentication_38fb587d3f7ced6d, []int{2} + return fileDescriptor_authentication_60086026974687e4, []int{2} } func (m *UserLogoutRequest) XXX_Unmarshal(b []byte) error { return m.Unmarshal(b) @@ -139,7 +141,7 @@ func (m *UserLogoutResponse) Reset() { *m = UserLogoutResponse{} } func (m *UserLogoutResponse) String() string { return proto.CompactTextString(m) } func (*UserLogoutResponse) ProtoMessage() {} func (*UserLogoutResponse) Descriptor() ([]byte, []int) { - return fileDescriptor_authentication_38fb587d3f7ced6d, []int{3} + return fileDescriptor_authentication_60086026974687e4, []int{3} } func (m *UserLogoutResponse) XXX_Unmarshal(b []byte) error { return m.Unmarshal(b) @@ -177,7 +179,7 @@ func (m *SessionCookie) Reset() { *m = SessionCookie{} } func (m *SessionCookie) String() string { return proto.CompactTextString(m) } func (*SessionCookie) ProtoMessage() {} func (*SessionCookie) Descriptor() ([]byte, []int) { - return fileDescriptor_authentication_38fb587d3f7ced6d, []int{4} + return fileDescriptor_authentication_60086026974687e4, []int{4} } func (m *SessionCookie) XXX_Unmarshal(b []byte) error { return m.Unmarshal(b) @@ -202,12 +204,127 @@ func (m *SessionCookie) XXX_DiscardUnknown() { var xxx_messageInfo_SessionCookie proto.InternalMessageInfo +// OIDCState is a message that is serialized and sent over with an OIDC authentication request +// when the identity provider triggers our callback, it returns the same state message back to +// us so that we can ensure that we're only processing responses that we originated. +type OIDCState struct { + // ID of node that originated the OIDC session. + NodeID github_com_cockroachdb_cockroach_pkg_roachpb.NodeID `protobuf:"varint,1,opt,name=node_id,json=nodeId,proto3,casttype=github.com/cockroachdb/cockroach/pkg/roachpb.NodeID" json:"node_id,omitempty"` + // Random bytes that the originating node stores to validate the callback response. + Secret []byte `protobuf:"bytes,2,opt,name=secret,proto3" json:"secret,omitempty"` +} + +func (m *OIDCState) Reset() { *m = OIDCState{} } +func (m *OIDCState) String() string { return proto.CompactTextString(m) } +func (*OIDCState) ProtoMessage() {} +func (*OIDCState) Descriptor() ([]byte, []int) { + return fileDescriptor_authentication_60086026974687e4, []int{5} +} +func (m *OIDCState) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *OIDCState) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + b = b[:cap(b)] + n, err := m.MarshalTo(b) + if err != nil { + return nil, err + } + return b[:n], nil +} +func (dst *OIDCState) XXX_Merge(src proto.Message) { + xxx_messageInfo_OIDCState.Merge(dst, src) +} +func (m *OIDCState) XXX_Size() int { + return m.Size() +} +func (m *OIDCState) XXX_DiscardUnknown() { + xxx_messageInfo_OIDCState.DiscardUnknown(m) +} + +var xxx_messageInfo_OIDCState proto.InternalMessageInfo + +// ValidateOIDCStateRequest is a message that one node sends to another to request +// a state validation. When an OIDC identity provider triggers our callback URL it +// sends back a serialized OIDCState (defined above). If the NodeID in that state +// does not match the node we're processing the callback with, we will request a +// validation from the appropriate node using this message. +type ValidateOIDCStateRequest struct { + State *OIDCState `protobuf:"bytes,1,opt,name=state,proto3" json:"state,omitempty"` +} + +func (m *ValidateOIDCStateRequest) Reset() { *m = ValidateOIDCStateRequest{} } +func (m *ValidateOIDCStateRequest) String() string { return proto.CompactTextString(m) } +func (*ValidateOIDCStateRequest) ProtoMessage() {} +func (*ValidateOIDCStateRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_authentication_60086026974687e4, []int{6} +} +func (m *ValidateOIDCStateRequest) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *ValidateOIDCStateRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + b = b[:cap(b)] + n, err := m.MarshalTo(b) + if err != nil { + return nil, err + } + return b[:n], nil +} +func (dst *ValidateOIDCStateRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_ValidateOIDCStateRequest.Merge(dst, src) +} +func (m *ValidateOIDCStateRequest) XXX_Size() int { + return m.Size() +} +func (m *ValidateOIDCStateRequest) XXX_DiscardUnknown() { + xxx_messageInfo_ValidateOIDCStateRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_ValidateOIDCStateRequest proto.InternalMessageInfo + +// ValidateOIDCStateResponse simply tells us if a given state was considered valid +// by the node. It will usually result in that state being cleared from the node's +// cache and discarded to prevent reuse. +type ValidateOIDCStateResponse struct { +} + +func (m *ValidateOIDCStateResponse) Reset() { *m = ValidateOIDCStateResponse{} } +func (m *ValidateOIDCStateResponse) String() string { return proto.CompactTextString(m) } +func (*ValidateOIDCStateResponse) ProtoMessage() {} +func (*ValidateOIDCStateResponse) Descriptor() ([]byte, []int) { + return fileDescriptor_authentication_60086026974687e4, []int{7} +} +func (m *ValidateOIDCStateResponse) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *ValidateOIDCStateResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + b = b[:cap(b)] + n, err := m.MarshalTo(b) + if err != nil { + return nil, err + } + return b[:n], nil +} +func (dst *ValidateOIDCStateResponse) XXX_Merge(src proto.Message) { + xxx_messageInfo_ValidateOIDCStateResponse.Merge(dst, src) +} +func (m *ValidateOIDCStateResponse) XXX_Size() int { + return m.Size() +} +func (m *ValidateOIDCStateResponse) XXX_DiscardUnknown() { + xxx_messageInfo_ValidateOIDCStateResponse.DiscardUnknown(m) +} + +var xxx_messageInfo_ValidateOIDCStateResponse proto.InternalMessageInfo + func init() { proto.RegisterType((*UserLoginRequest)(nil), "cockroach.server.serverpb.UserLoginRequest") proto.RegisterType((*UserLoginResponse)(nil), "cockroach.server.serverpb.UserLoginResponse") proto.RegisterType((*UserLogoutRequest)(nil), "cockroach.server.serverpb.UserLogoutRequest") proto.RegisterType((*UserLogoutResponse)(nil), "cockroach.server.serverpb.UserLogoutResponse") proto.RegisterType((*SessionCookie)(nil), "cockroach.server.serverpb.SessionCookie") + proto.RegisterType((*OIDCState)(nil), "cockroach.server.serverpb.OIDCState") + proto.RegisterType((*ValidateOIDCStateRequest)(nil), "cockroach.server.serverpb.ValidateOIDCStateRequest") + proto.RegisterType((*ValidateOIDCStateResponse)(nil), "cockroach.server.serverpb.ValidateOIDCStateResponse") } // Reference imports to suppress errors if they are not otherwise used. @@ -224,6 +341,13 @@ const _ = grpc.SupportPackageIsVersion4 type LogInClient interface { // UserLogin is used to create a web authentication session. UserLogin(ctx context.Context, in *UserLoginRequest, opts ...grpc.CallOption) (*UserLoginResponse, error) + // ValidateOIDCState is used for nodes to validate OIDC state that another node + // may have cached since auth requests can originate and be completed at any + // node in the cluster. + // + // This endpoint does not have an HTTP API since we only intend to use it for + // inter-node communication. + ValidateOIDCState(ctx context.Context, in *ValidateOIDCStateRequest, opts ...grpc.CallOption) (*ValidateOIDCStateResponse, error) } type logInClient struct { @@ -243,10 +367,26 @@ func (c *logInClient) UserLogin(ctx context.Context, in *UserLoginRequest, opts return out, nil } +func (c *logInClient) ValidateOIDCState(ctx context.Context, in *ValidateOIDCStateRequest, opts ...grpc.CallOption) (*ValidateOIDCStateResponse, error) { + out := new(ValidateOIDCStateResponse) + err := c.cc.Invoke(ctx, "/cockroach.server.serverpb.LogIn/ValidateOIDCState", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + // LogInServer is the server API for LogIn service. type LogInServer interface { // UserLogin is used to create a web authentication session. UserLogin(context.Context, *UserLoginRequest) (*UserLoginResponse, error) + // ValidateOIDCState is used for nodes to validate OIDC state that another node + // may have cached since auth requests can originate and be completed at any + // node in the cluster. + // + // This endpoint does not have an HTTP API since we only intend to use it for + // inter-node communication. + ValidateOIDCState(context.Context, *ValidateOIDCStateRequest) (*ValidateOIDCStateResponse, error) } func RegisterLogInServer(s *grpc.Server, srv LogInServer) { @@ -271,6 +411,24 @@ func _LogIn_UserLogin_Handler(srv interface{}, ctx context.Context, dec func(int return interceptor(ctx, in, info, handler) } +func _LogIn_ValidateOIDCState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ValidateOIDCStateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(LogInServer).ValidateOIDCState(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/cockroach.server.serverpb.LogIn/ValidateOIDCState", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(LogInServer).ValidateOIDCState(ctx, req.(*ValidateOIDCStateRequest)) + } + return interceptor(ctx, in, info, handler) +} + var _LogIn_serviceDesc = grpc.ServiceDesc{ ServiceName: "cockroach.server.serverpb.LogIn", HandlerType: (*LogInServer)(nil), @@ -279,6 +437,10 @@ var _LogIn_serviceDesc = grpc.ServiceDesc{ MethodName: "UserLogin", Handler: _LogIn_UserLogin_Handler, }, + { + MethodName: "ValidateOIDCState", + Handler: _LogIn_ValidateOIDCState_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "server/serverpb/authentication.proto", @@ -463,6 +625,81 @@ func (m *SessionCookie) MarshalTo(dAtA []byte) (int, error) { return i, nil } +func (m *OIDCState) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalTo(dAtA) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OIDCState) MarshalTo(dAtA []byte) (int, error) { + var i int + _ = i + var l int + _ = l + if m.NodeID != 0 { + dAtA[i] = 0x8 + i++ + i = encodeVarintAuthentication(dAtA, i, uint64(m.NodeID)) + } + if len(m.Secret) > 0 { + dAtA[i] = 0x12 + i++ + i = encodeVarintAuthentication(dAtA, i, uint64(len(m.Secret))) + i += copy(dAtA[i:], m.Secret) + } + return i, nil +} + +func (m *ValidateOIDCStateRequest) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalTo(dAtA) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ValidateOIDCStateRequest) MarshalTo(dAtA []byte) (int, error) { + var i int + _ = i + var l int + _ = l + if m.State != nil { + dAtA[i] = 0xa + i++ + i = encodeVarintAuthentication(dAtA, i, uint64(m.State.Size())) + n1, err := m.State.MarshalTo(dAtA[i:]) + if err != nil { + return 0, err + } + i += n1 + } + return i, nil +} + +func (m *ValidateOIDCStateResponse) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalTo(dAtA) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ValidateOIDCStateResponse) MarshalTo(dAtA []byte) (int, error) { + var i int + _ = i + var l int + _ = l + return i, nil +} + func encodeVarintAuthentication(dAtA []byte, offset int, v uint64) int { for v >= 1<<7 { dAtA[offset] = uint8(v&0x7f | 0x80) @@ -532,6 +769,44 @@ func (m *SessionCookie) Size() (n int) { return n } +func (m *OIDCState) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.NodeID != 0 { + n += 1 + sovAuthentication(uint64(m.NodeID)) + } + l = len(m.Secret) + if l > 0 { + n += 1 + l + sovAuthentication(uint64(l)) + } + return n +} + +func (m *ValidateOIDCStateRequest) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.State != nil { + l = m.State.Size() + n += 1 + l + sovAuthentication(uint64(l)) + } + return n +} + +func (m *ValidateOIDCStateResponse) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + return n +} + func sovAuthentication(x uint64) (n int) { for { n++ @@ -903,6 +1178,239 @@ func (m *SessionCookie) Unmarshal(dAtA []byte) error { } return nil } +func (m *OIDCState) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowAuthentication + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OIDCState: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OIDCState: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field NodeID", wireType) + } + m.NodeID = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowAuthentication + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.NodeID |= (github_com_cockroachdb_cockroach_pkg_roachpb.NodeID(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Secret", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowAuthentication + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLengthAuthentication + } + postIndex := iNdEx + byteLen + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Secret = append(m.Secret[:0], dAtA[iNdEx:postIndex]...) + if m.Secret == nil { + m.Secret = []byte{} + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipAuthentication(dAtA[iNdEx:]) + if err != nil { + return err + } + if skippy < 0 { + return ErrInvalidLengthAuthentication + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ValidateOIDCStateRequest) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowAuthentication + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ValidateOIDCStateRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ValidateOIDCStateRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field State", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowAuthentication + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthAuthentication + } + postIndex := iNdEx + msglen + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.State == nil { + m.State = &OIDCState{} + } + if err := m.State.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipAuthentication(dAtA[iNdEx:]) + if err != nil { + return err + } + if skippy < 0 { + return ErrInvalidLengthAuthentication + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ValidateOIDCStateResponse) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowAuthentication + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ValidateOIDCStateResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ValidateOIDCStateResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + default: + iNdEx = preIndex + skippy, err := skipAuthentication(dAtA[iNdEx:]) + if err != nil { + return err + } + if skippy < 0 { + return ErrInvalidLengthAuthentication + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} func skipAuthentication(dAtA []byte) (n int, err error) { l := len(dAtA) iNdEx := 0 @@ -1009,33 +1517,41 @@ var ( ) func init() { - proto.RegisterFile("server/serverpb/authentication.proto", fileDescriptor_authentication_38fb587d3f7ced6d) -} - -var fileDescriptor_authentication_38fb587d3f7ced6d = []byte{ - // 378 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x8c, 0x92, 0xcd, 0x6a, 0xdb, 0x40, - 0x14, 0x85, 0x35, 0x2a, 0x55, 0xed, 0x69, 0x4b, 0xeb, 0xa9, 0x31, 0xae, 0x28, 0xd3, 0x22, 0xba, - 0x28, 0x6e, 0x2b, 0x81, 0xb3, 0xcb, 0x26, 0xe0, 0x64, 0xe3, 0x60, 0x08, 0x28, 0x64, 0x93, 0x9d, - 0x2c, 0x0f, 0xf2, 0x60, 0x67, 0xae, 0x32, 0x33, 0x4a, 0x48, 0x96, 0x26, 0x0f, 0x10, 0xc8, 0x4b, - 0x79, 0x69, 0xc8, 0xc6, 0xab, 0x90, 0xc8, 0x79, 0x90, 0xa0, 0x1f, 0xff, 0x10, 0x08, 0x78, 0x25, - 0xdd, 0x7b, 0xce, 0x9c, 0xf9, 0xee, 0x65, 0xf0, 0x6f, 0xc5, 0xe4, 0x05, 0x93, 0x5e, 0xf1, 0x89, - 0xfb, 0x5e, 0x90, 0xe8, 0x21, 0x13, 0x9a, 0x87, 0x81, 0xe6, 0x20, 0xdc, 0x58, 0x82, 0x06, 0xf2, - 0x3d, 0x84, 0x70, 0x24, 0x21, 0x08, 0x87, 0x6e, 0x61, 0x74, 0x97, 0x7e, 0xbb, 0x1e, 0x41, 0x04, - 0xb9, 0xcb, 0xcb, 0xfe, 0x8a, 0x03, 0xf6, 0x8f, 0x08, 0x20, 0x1a, 0x33, 0x2f, 0x88, 0xb9, 0x17, - 0x08, 0x01, 0x3a, 0x4f, 0x53, 0x85, 0xea, 0x1c, 0xe2, 0xaf, 0x27, 0x8a, 0xc9, 0x1e, 0x44, 0x5c, - 0xf8, 0xec, 0x3c, 0x61, 0x4a, 0x13, 0x1b, 0x57, 0x12, 0xc5, 0xa4, 0x08, 0xce, 0x58, 0x13, 0xfd, - 0x42, 0x7f, 0xaa, 0xfe, 0xaa, 0xce, 0xb4, 0x38, 0x50, 0xea, 0x12, 0xe4, 0xa0, 0x69, 0x16, 0xda, - 0xb2, 0x76, 0xbe, 0xe1, 0xda, 0x46, 0x96, 0x8a, 0x41, 0x28, 0xb6, 0xd1, 0x84, 0x44, 0x97, 0x37, - 0x38, 0x75, 0x4c, 0x36, 0x9b, 0xa5, 0x75, 0x0f, 0x7f, 0x3e, 0x66, 0x4a, 0x71, 0x10, 0xfb, 0x00, - 0x23, 0xce, 0x48, 0x03, 0x9b, 0x7c, 0x90, 0x23, 0xbc, 0xeb, 0x58, 0xe9, 0xc3, 0x4f, 0xb3, 0x7b, - 0xe0, 0x9b, 0x7c, 0x40, 0x1a, 0xd8, 0x52, 0x2c, 0x94, 0x4c, 0xe7, 0x08, 0x9f, 0xfc, 0xb2, 0x6a, - 0x4f, 0x10, 0x7e, 0xdf, 0x83, 0xa8, 0x2b, 0xc8, 0x15, 0xae, 0xae, 0x50, 0xc8, 0x5f, 0xf7, 0xcd, - 0x9d, 0xb9, 0xaf, 0x87, 0xb7, 0xff, 0x6d, 0x67, 0x2e, 0x91, 0x6b, 0x93, 0xfb, 0xe7, 0x3b, 0xf3, - 0xa3, 0x63, 0x79, 0xe3, 0xac, 0xbf, 0x8b, 0x5a, 0xed, 0x1b, 0x84, 0xad, 0x1e, 0x44, 0x47, 0x89, - 0x26, 0xd7, 0x18, 0xaf, 0xc7, 0x24, 0x5b, 0x24, 0xaf, 0x57, 0x64, 0xff, 0xdf, 0xd2, 0x5d, 0x82, - 0x7c, 0xc9, 0x41, 0xaa, 0xe4, 0x43, 0x06, 0x02, 0x89, 0xee, 0xb4, 0xa6, 0x4f, 0xd4, 0x98, 0xa6, - 0x14, 0xcd, 0x52, 0x8a, 0xe6, 0x29, 0x45, 0x8f, 0x29, 0x45, 0xb7, 0x0b, 0x6a, 0xcc, 0x16, 0xd4, - 0x98, 0x2f, 0xa8, 0x71, 0x5a, 0x59, 0xe6, 0xf5, 0xad, 0xfc, 0x2d, 0xec, 0xbc, 0x04, 0x00, 0x00, - 0xff, 0xff, 0x5e, 0xfd, 0xe4, 0x94, 0x82, 0x02, 0x00, 0x00, + proto.RegisterFile("server/serverpb/authentication.proto", fileDescriptor_authentication_60086026974687e4) +} + +var fileDescriptor_authentication_60086026974687e4 = []byte{ + // 504 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x94, 0x53, 0x4d, 0x6f, 0xd3, 0x40, + 0x10, 0x8d, 0x8d, 0xe2, 0x36, 0x53, 0x10, 0x64, 0xa9, 0xaa, 0xd4, 0x20, 0x07, 0x59, 0x3d, 0xa0, + 0x02, 0xb6, 0x94, 0x70, 0xea, 0x05, 0x91, 0xe6, 0x12, 0x14, 0x51, 0x29, 0x15, 0x3d, 0xf4, 0x82, + 0xd6, 0xf6, 0xca, 0x59, 0x25, 0xdd, 0x31, 0xde, 0x35, 0x08, 0x2e, 0x20, 0x04, 0x77, 0x24, 0xfe, + 0x54, 0x8f, 0x95, 0xb8, 0xf4, 0x14, 0x81, 0xc3, 0xaf, 0xe0, 0x84, 0xfc, 0x91, 0x0f, 0x01, 0xa9, + 0xc2, 0x29, 0xf3, 0xf1, 0xe6, 0xcd, 0xcb, 0x9b, 0x35, 0xec, 0x49, 0x16, 0xbf, 0x66, 0xb1, 0x5b, + 0xfc, 0x44, 0x9e, 0x4b, 0x13, 0x35, 0x64, 0x42, 0x71, 0x9f, 0x2a, 0x8e, 0xc2, 0x89, 0x62, 0x54, + 0x48, 0x76, 0x7d, 0xf4, 0x47, 0x31, 0x52, 0x7f, 0xe8, 0x14, 0x40, 0x67, 0x86, 0x37, 0xb7, 0x43, + 0x0c, 0x31, 0x47, 0xb9, 0x59, 0x54, 0x0c, 0x98, 0x77, 0x43, 0xc4, 0x70, 0xcc, 0x5c, 0x1a, 0x71, + 0x97, 0x0a, 0x81, 0x2a, 0x67, 0x93, 0x45, 0xd7, 0x7e, 0x06, 0xb7, 0x5e, 0x48, 0x16, 0xf7, 0x31, + 0xe4, 0x62, 0xc0, 0x5e, 0x25, 0x4c, 0x2a, 0x62, 0xc2, 0x66, 0x22, 0x59, 0x2c, 0xe8, 0x19, 0x6b, + 0x68, 0xf7, 0xb4, 0xfb, 0xb5, 0xc1, 0x3c, 0xcf, 0x7a, 0x11, 0x95, 0xf2, 0x0d, 0xc6, 0x41, 0x43, + 0x2f, 0x7a, 0xb3, 0xdc, 0xbe, 0x0d, 0xf5, 0x25, 0x2e, 0x19, 0xa1, 0x90, 0x6c, 0xa9, 0x88, 0x89, + 0x2a, 0x37, 0xd8, 0xdb, 0x40, 0x96, 0x8b, 0x25, 0xf4, 0x09, 0xdc, 0x38, 0x66, 0x52, 0x72, 0x14, + 0x87, 0x88, 0x23, 0xce, 0xc8, 0x0e, 0xe8, 0x3c, 0xc8, 0x25, 0x5c, 0xeb, 0x18, 0xe9, 0xa4, 0xa9, + 0xf7, 0xba, 0x03, 0x9d, 0x07, 0x64, 0x07, 0x0c, 0xc9, 0xfc, 0x98, 0xa9, 0x5c, 0xc2, 0xf5, 0x41, + 0x99, 0xd9, 0xef, 0xa1, 0x76, 0xd4, 0xeb, 0x1e, 0x1e, 0x2b, 0xaa, 0x18, 0x39, 0x85, 0x0d, 0x81, + 0x01, 0x7b, 0x59, 0x32, 0x54, 0x3b, 0x4f, 0xd3, 0x49, 0xd3, 0x78, 0x8e, 0x01, 0xeb, 0x75, 0x7f, + 0x4d, 0x9a, 0xed, 0x90, 0xab, 0x61, 0xe2, 0x39, 0x3e, 0x9e, 0xb9, 0x73, 0x4b, 0x03, 0x6f, 0x11, + 0xbb, 0xd1, 0x28, 0x74, 0xf3, 0x28, 0xf2, 0x9c, 0x62, 0x6c, 0x60, 0x64, 0x8c, 0xbd, 0xd5, 0x02, + 0x4e, 0xa0, 0x71, 0x42, 0xc7, 0x3c, 0xa0, 0x8a, 0xcd, 0x85, 0xcc, 0x5c, 0x3d, 0x80, 0xaa, 0xcc, + 0xf2, 0x5c, 0xcd, 0x56, 0x6b, 0xcf, 0x59, 0x79, 0x48, 0x67, 0x31, 0x5b, 0x8c, 0xd8, 0x77, 0x60, + 0xf7, 0x1f, 0xbc, 0x85, 0x6d, 0xad, 0xcf, 0x3a, 0x54, 0xfb, 0x18, 0xf6, 0x04, 0x79, 0x0b, 0xb5, + 0xf9, 0x01, 0xc8, 0x83, 0x2b, 0x16, 0xfc, 0x79, 0x72, 0xf3, 0xe1, 0x7a, 0xe0, 0xf2, 0x50, 0xf5, + 0x8f, 0xdf, 0x7e, 0x7e, 0xd5, 0xb7, 0x6c, 0xc3, 0x1d, 0x67, 0xf5, 0x03, 0x6d, 0x9f, 0x7c, 0xd0, + 0xa0, 0xfe, 0x97, 0x44, 0xd2, 0xbe, 0x82, 0x76, 0x95, 0x51, 0xe6, 0xe3, 0xff, 0x1b, 0x2a, 0x35, + 0x55, 0x5a, 0x9f, 0x34, 0x30, 0xfa, 0x18, 0x1e, 0x25, 0x8a, 0xbc, 0x03, 0x58, 0xbc, 0x2f, 0xb2, + 0xc6, 0x9f, 0x5b, 0xbc, 0x4d, 0xf3, 0xd1, 0x9a, 0xe8, 0x72, 0xef, 0xcd, 0xdc, 0x8b, 0x1a, 0xd9, + 0xc8, 0xbc, 0xc0, 0x44, 0x75, 0xf6, 0xcf, 0x7f, 0x58, 0x95, 0xf3, 0xd4, 0xd2, 0x2e, 0x52, 0x4b, + 0xbb, 0x4c, 0x2d, 0xed, 0x7b, 0x6a, 0x69, 0x5f, 0xa6, 0x56, 0xe5, 0x62, 0x6a, 0x55, 0x2e, 0xa7, + 0x56, 0xe5, 0x74, 0x73, 0xc6, 0xe7, 0x19, 0xf9, 0x47, 0xd8, 0xfe, 0x1d, 0x00, 0x00, 0xff, 0xff, + 0x40, 0x9f, 0xd2, 0xb7, 0xfb, 0x03, 0x00, 0x00, } diff --git a/pkg/server/serverpb/authentication.proto b/pkg/server/serverpb/authentication.proto index 3cb5308f8b41..ee7597e9483a 100644 --- a/pkg/server/serverpb/authentication.proto +++ b/pkg/server/serverpb/authentication.proto @@ -50,6 +50,34 @@ message SessionCookie { bytes secret = 2; } +// OIDCState is a message that is serialized and sent over with an OIDC authentication request +// when the identity provider triggers our callback, it returns the same state message back to +// us so that we can ensure that we're only processing responses that we originated. +message OIDCState { + // ID of node that originated the OIDC session. + int32 node_id = 1 [ + (gogoproto.customname) = "NodeID", + (gogoproto.casttype) = + "github.com/cockroachdb/cockroach/pkg/roachpb.NodeID" + ]; + // Random bytes that the originating node stores to validate the callback response. + bytes secret = 2; +} + +// ValidateOIDCStateRequest is a message that one node sends to another to request +// a state validation. When an OIDC identity provider triggers our callback URL it +// sends back a serialized OIDCState (defined above). If the NodeID in that state +// does not match the node we're processing the callback with, we will request a +// validation from the appropriate node using this message. +message ValidateOIDCStateRequest { + OIDCState state = 1; +} + +// ValidateOIDCStateResponse simply tells us if a given state was considered valid +// by the node. It will usually result in that state being cleared from the node's +// cache and discarded to prevent reuse. +message ValidateOIDCStateResponse {} + // LogIn and LogOut are the GRPC APIs used to create web authentication sessions. // Intended for use over GRPC-Gateway, which identifies sessions using HTTP // cookies. @@ -65,13 +93,21 @@ service LogIn { body: "*" }; } + + // ValidateOIDCState is used for nodes to validate OIDC state that another node + // may have cached since auth requests can originate and be completed at any + // node in the cluster. + // + // This endpoint does not have an HTTP API since we only intend to use it for + // inter-node communication. + rpc ValidateOIDCState(ValidateOIDCStateRequest) returns (ValidateOIDCStateResponse) {} } service LogOut { - // UserLogout terminates an active authentication session. - rpc UserLogout(UserLogoutRequest) returns (UserLogoutResponse) { - option (google.api.http) = { + // UserLogout terminates an active authentication session. + rpc UserLogout(UserLogoutRequest) returns (UserLogoutResponse) { + option (google.api.http) = { get: "/logout" }; - } + } } diff --git a/pkg/ui/src/redux/login.ts b/pkg/ui/src/redux/login.ts index d388a573773f..ceb3ae5d20f4 100644 --- a/pkg/ui/src/redux/login.ts +++ b/pkg/ui/src/redux/login.ts @@ -150,12 +150,18 @@ export interface LoginAPIState { loggedInUser: string; error: Error; inProgress: boolean; + displayPasswordLogin: boolean; + displayOIDCButton: boolean; + oidcButtonText: string; } export const emptyLoginState: LoginAPIState = { loggedInUser: dataFromServer.LoggedInUser, error: null, inProgress: false, + displayPasswordLogin: dataFromServer.PasswordLoginEnabled, + displayOIDCButton: dataFromServer.OIDCLoginEnabled, + oidcButtonText: dataFromServer.OIDCButtonText, }; // Actions @@ -244,24 +250,28 @@ export function loginReducer(state = emptyLoginState, action: Action): LoginAPIS switch (action.type) { case LOGIN_BEGIN: return { + ...state, loggedInUser: null, error: null, inProgress: true, }; case LOGIN_SUCCESS: return { + ...state, loggedInUser: (action as LoginSuccessAction).loggedInUser, inProgress: false, error: null, }; case LOGIN_FAILURE: return { + ...state, loggedInUser: null, inProgress: false, error: (action as LoginFailureAction).error, }; case LOGOUT_BEGIN: return { + ...state, loggedInUser: state.loggedInUser, inProgress: true, error: null, diff --git a/pkg/ui/src/util/dataFromServer.ts b/pkg/ui/src/util/dataFromServer.ts index 8c962f5761a7..6d5066ce297c 100644 --- a/pkg/ui/src/util/dataFromServer.ts +++ b/pkg/ui/src/util/dataFromServer.ts @@ -15,6 +15,9 @@ export interface DataFromServer { Tag: string; Version: string; NodeID: string; + PasswordLoginEnabled: boolean; + OIDCLoginEnabled: boolean; + OIDCButtonText: string; } // Tell TypeScript about `window.dataFromServer`, which is set in a script diff --git a/pkg/ui/src/views/login/loginPage.styl b/pkg/ui/src/views/login/loginPage.styl index d3a034dda70c..560ba372b90d 100644 --- a/pkg/ui/src/views/login/loginPage.styl +++ b/pkg/ui/src/views/login/loginPage.styl @@ -90,6 +90,12 @@ &:disabled cursor pointer + .submit-button-oidc + margin-top: 10px + width 100% + &:disabled + cursor pointer + &__error @extend $text--body-strong padding 14px 20px @@ -123,4 +129,4 @@ .sql-keyword color $colors--functional-purple-6 .sql-string - color $colors--functional-orange-4 \ No newline at end of file + color $colors--functional-orange-4 diff --git a/pkg/ui/src/views/login/loginPage.tsx b/pkg/ui/src/views/login/loginPage.tsx index 41ae1a55a051..8ab1b408fead 100644 --- a/pkg/ui/src/views/login/loginPage.tsx +++ b/pkg/ui/src/views/login/loginPage.tsx @@ -27,15 +27,29 @@ export interface LoginPageProps { handleLogin: (username: string, password: string) => Promise; } -interface LoginPageState { +type Props = LoginPageProps & RouteComponentProps; + +const OIDCLoginButton = ({loginState}: {loginState: LoginAPIState}) => { + if (loginState.displayOIDCButton) { + return ( + + + + ); + } else { + return null; + } +}; + +interface PasswordLoginState { username?: string; password?: string; } -type Props = LoginPageProps & RouteComponentProps; - -export class LoginPage extends React.Component { - constructor(props: Props) { +class PasswordLoginForm extends React.Component { + constructor(props: LoginPageProps) { super(props); this.state = { username: "", @@ -56,6 +70,52 @@ export class LoginPage extends React.Component { }); } + handleSubmit = (evt: React.FormEvent) => { + const { handleLogin} = this.props; + const { username, password } = this.state; + evt.preventDefault(); + + handleLogin(username, password); + } + + render() { + const { username, password } = this.state; + const { loginState } = this.props; + + if (loginState.displayPasswordLogin) { + return ( +
+ + + + + ); + } else { + return null; + } + } +} + +export class LoginPage extends React.Component { + constructor(props: Props) { + super(props); + } + componentDidUpdate() { const { loginState: { loggedInUser } } = this.props; if (loggedInUser !== null) { @@ -69,14 +129,6 @@ export class LoginPage extends React.Component { } } - handleSubmit = (evt: React.FormEvent) => { - const { handleLogin} = this.props; - const { username, password } = this.state; - evt.preventDefault(); - - handleLogin(username, password); - } - renderError() { const { error } = this.props.loginState; @@ -97,7 +149,6 @@ export class LoginPage extends React.Component { } render() { - const { username, password } = this.state; const { loginState } = this.props; return ( @@ -110,25 +161,8 @@ export class LoginPage extends React.Component {
Log in to the Admin UI {this.renderError()} -
- - - - + +
diff --git a/pkg/ui/ui.go b/pkg/ui/ui.go index 97e78c262f80..4e419251d268 100644 --- a/pkg/ui/ui.go +++ b/pkg/ui/ui.go @@ -93,6 +93,26 @@ type indexHTMLArgs struct { Tag string Version string NodeID string + PasswordLoginEnabled bool + OIDCLoginEnabled bool + OIDCButtonText string +} + +// OIDCUIConf is a variable that stores data required by the +// Admin UI to display and manage the OIDC login flow. It is +// provided by the `oidcAuthenticationServer` at runtime +// since that's where all the OIDC configuration is centralized. +type OIDCUIConf struct { + ButtonText string + Enabled bool +} + +// OIDCUI is an interface that our OIDC configuration must implement in order to be able +// to pass relevant configuration info to the ui module. This is to pass through variables that +// are necessary to render an appropriate user interface for OIDC support and to set the state +// cookie that OIDC requires for securing auth requests. +type OIDCUI interface { + GetOIDCConf() OIDCUIConf } // bareIndexHTML is used in place of indexHTMLTemplate when the binary is built @@ -109,6 +129,7 @@ type Config struct { LoginEnabled bool NodeID *base.NodeIDContainer GetUser func(ctx context.Context) *string + OIDC OIDCUI } // Handler returns an http.Handler that serves the UI, @@ -133,6 +154,8 @@ func Handler(cfg Config) http.Handler { return } + oidcConf := cfg.OIDC.GetOIDCConf() + if err := indexHTMLTemplate.Execute(w, indexHTMLArgs{ ExperimentalUseLogin: cfg.ExperimentalUseLogin, LoginEnabled: cfg.LoginEnabled, @@ -140,6 +163,9 @@ func Handler(cfg Config) http.Handler { Tag: buildInfo.Tag, Version: build.VersionPrefix(), NodeID: cfg.NodeID.String(), + PasswordLoginEnabled: true, + OIDCLoginEnabled: oidcConf.Enabled, + OIDCButtonText: oidcConf.ButtonText, }); err != nil { err = errors.Wrap(err, "templating index.html") http.Error(w, err.Error(), 500) diff --git a/vendor b/vendor index da781fd28cba..a80e487820e0 160000 --- a/vendor +++ b/vendor @@ -1 +1 @@ -Subproject commit da781fd28cba60fb7023b1891be8f356f3b25be1 +Subproject commit a80e487820e090a71e5c53787247c178441d6c2c