[ARVADOS] created: 2.1.0-34-gb8de8845c

Git user git at public.arvados.org
Wed Oct 21 13:09:29 UTC 2020


        at  b8de8845c856f7fe1232e5f048824211d1207ee7 (commit)


commit b8de8845c856f7fe1232e5f048824211d1207ee7
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Thu Sep 24 10:05:30 2020 -0400

    16669: Fix test.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/controller/integration_test.go b/lib/controller/integration_test.go
index ce02a082a..3da01ca68 100644
--- a/lib/controller/integration_test.go
+++ b/lib/controller/integration_test.go
@@ -587,7 +587,7 @@ func (s *IntegrationSuite) TestOIDCAccessTokenAuth(c *check.C) {
 		{
 			user, err := conn.UserGetCurrent(ctx, arvados.GetOptions{})
 			c.Assert(err, check.IsNil)
-			c.Check(user.FullName, check.Equals, "Active User")
+			c.Check(user.FullName, check.Equals, "Example User")
 			coll, err = conn.CollectionGet(ctx, arvados.GetOptions{UUID: coll.UUID})
 			c.Assert(err, check.IsNil)
 			c.Check(coll.ManifestText, check.Not(check.Equals), "")

commit 27ab60e21b3cf9c908716ae74e63aba8e4cb6349
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Wed Sep 23 17:37:34 2020 -0400

    16669: Fix pass-through of cached remote token.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/controller/federation.go b/lib/controller/federation.go
index aceaba808..cab5e4c4c 100644
--- a/lib/controller/federation.go
+++ b/lib/controller/federation.go
@@ -263,17 +263,20 @@ func (h *Handler) saltAuthToken(req *http.Request, remote string) (updatedReq *h
 		return updatedReq, nil
 	}
 
+	ctxlog.FromContext(req.Context()).Infof("saltAuthToken: cluster %s token %s remote %s", h.Cluster.ClusterID, creds.Tokens[0], remote)
 	token, err := auth.SaltToken(creds.Tokens[0], remote)
 
 	if err == auth.ErrObsoleteToken {
-		// If the token exists in our own database, salt it
-		// for the remote. Otherwise, assume it was issued by
-		// the remote, and pass it through unmodified.
+		// If the token exists in our own database for our own
+		// user, salt it for the remote. Otherwise, assume it
+		// was issued by the remote, and pass it through
+		// unmodified.
 		currentUser, ok, err := h.validateAPItoken(req, creds.Tokens[0])
 		if err != nil {
 			return nil, err
-		} else if !ok {
-			// Not ours; pass through unmodified.
+		} else if !ok || strings.HasPrefix(currentUser.UUID, remote) {
+			// Unknown, or cached + belongs to remote;
+			// pass through unmodified.
 			token = creds.Tokens[0]
 		} else {
 			// Found; make V2 version and salt it.
diff --git a/lib/controller/federation/conn.go b/lib/controller/federation/conn.go
index f07c3b631..986faa7b0 100644
--- a/lib/controller/federation/conn.go
+++ b/lib/controller/federation/conn.go
@@ -79,6 +79,14 @@ func saltedTokenProvider(local backend, remoteID string) rpc.TokenProvider {
 				} else if err != nil {
 					return nil, err
 				}
+				if strings.HasPrefix(aca.UUID, remoteID) {
+					// We have it cached here, but
+					// the token belongs to the
+					// remote target itself, so
+					// pass it through unmodified.
+					tokens = append(tokens, token)
+					continue
+				}
 				salted, err := auth.SaltToken(aca.TokenV2(), remoteID)
 				if err != nil {
 					return nil, err
diff --git a/lib/controller/localdb/login_oidc.go b/lib/controller/localdb/login_oidc.go
index 8909e0a72..2da7ca5cc 100644
--- a/lib/controller/localdb/login_oidc.go
+++ b/lib/controller/localdb/login_oidc.go
@@ -380,7 +380,7 @@ func (ta *oidcTokenAuthorizer) WrapCalls(origFunc api.RoutableFunc) api.Routable
 // if so, ensures that an api_client_authorizations row exists so that
 // RailsAPI will accept it as an Arvados token.
 func (ta *oidcTokenAuthorizer) registerToken(ctx context.Context, tok string) error {
-	if strings.HasPrefix(tok, "v2/") {
+	if tok == ta.ctrl.Cluster.SystemRootToken || strings.HasPrefix(tok, "v2/") {
 		return nil
 	}
 	if cached, hit := ta.cache.Get(tok); !hit {
diff --git a/services/api/app/models/api_client_authorization.rb b/services/api/app/models/api_client_authorization.rb
index 6a34ed955..74a4c1efa 100644
--- a/services/api/app/models/api_client_authorization.rb
+++ b/services/api/app/models/api_client_authorization.rb
@@ -206,7 +206,7 @@ class ApiClientAuthorization < ArvadosModel
         # below. If so, we'll stuff the database with hmac instead of
         # the real OIDC token.
         upstream_cluster_id = Rails.configuration.Login.LoginCluster
-        token_uuid = generate_uuid
+        token_uuid = upstream_cluster_id + generate_uuid[5..27]
         secret = hmac
       else
         return nil

commit 0e03883d091aa7b577bd4f9575ecd298861d4ce1
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Tue Sep 22 11:03:45 2020 -0400

    16669: Fix access token cache panic.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/controller/auth_test.go b/lib/controller/auth_test.go
index a188c3082..ad214b160 100644
--- a/lib/controller/auth_test.go
+++ b/lib/controller/auth_test.go
@@ -115,4 +115,12 @@ func (s *AuthSuite) TestLocalOIDCAccessToken(c *check.C) {
 	c.Check(json.NewDecoder(resp.Body).Decode(&u), check.IsNil)
 	c.Check(u.UUID, check.Equals, arvadostest.ActiveUserUUID)
 	c.Check(u.OwnerUUID, check.Equals, "zzzzz-tpzed-000000000000000")
+
+	// Request again to exercise cache.
+	req = httptest.NewRequest("GET", "/arvados/v1/users/current", nil)
+	req.Header.Set("Authorization", "Bearer "+s.fakeProvider.ValidAccessToken())
+	rr = httptest.NewRecorder()
+	s.testServer.Server.Handler.ServeHTTP(rr, req)
+	resp = rr.Result()
+	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
 }
diff --git a/lib/controller/localdb/login_oidc.go b/lib/controller/localdb/login_oidc.go
index b74d22f8e..8909e0a72 100644
--- a/lib/controller/localdb/login_oidc.go
+++ b/lib/controller/localdb/login_oidc.go
@@ -395,7 +395,7 @@ func (ta *oidcTokenAuthorizer) registerToken(ctx context.Context, tok string) er
 		}
 	} else {
 		// cached positive result
-		aca := cached.(*arvados.APIClientAuthorization)
+		aca := cached.(arvados.APIClientAuthorization)
 		var expiring bool
 		if aca.ExpiresAt != "" {
 			t, err := time.Parse(time.RFC3339Nano, aca.ExpiresAt)

commit 784cff6b13e4b95435e4a6f9c16e6fee66797375
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Tue Sep 22 10:59:13 2020 -0400

    16669: Test access tokens in federation scenario.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/controller/integration_test.go b/lib/controller/integration_test.go
index 077493ffc..ce02a082a 100644
--- a/lib/controller/integration_test.go
+++ b/lib/controller/integration_test.go
@@ -9,6 +9,7 @@ import (
 	"context"
 	"encoding/json"
 	"io"
+	"io/ioutil"
 	"math"
 	"net"
 	"net/http"
@@ -22,6 +23,7 @@ import (
 	"git.arvados.org/arvados.git/lib/service"
 	"git.arvados.org/arvados.git/sdk/go/arvados"
 	"git.arvados.org/arvados.git/sdk/go/arvadosclient"
+	"git.arvados.org/arvados.git/sdk/go/arvadostest"
 	"git.arvados.org/arvados.git/sdk/go/auth"
 	"git.arvados.org/arvados.git/sdk/go/ctxlog"
 	"git.arvados.org/arvados.git/sdk/go/keepclient"
@@ -38,6 +40,7 @@ type testCluster struct {
 
 type IntegrationSuite struct {
 	testClusters map[string]*testCluster
+	oidcprovider *arvadostest.OIDCProvider
 }
 
 func (s *IntegrationSuite) SetUpSuite(c *check.C) {
@@ -47,6 +50,14 @@ func (s *IntegrationSuite) SetUpSuite(c *check.C) {
 	}
 
 	cwd, _ := os.Getwd()
+
+	s.oidcprovider = arvadostest.NewOIDCProvider(c)
+	s.oidcprovider.AuthEmail = "user at example.com"
+	s.oidcprovider.AuthEmailVerified = true
+	s.oidcprovider.AuthName = "Example User"
+	s.oidcprovider.ValidClientID = "clientid"
+	s.oidcprovider.ValidClientSecret = "clientsecret"
+
 	s.testClusters = map[string]*testCluster{
 		"z1111": nil,
 		"z2222": nil,
@@ -105,6 +116,24 @@ func (s *IntegrationSuite) SetUpSuite(c *check.C) {
         ActivateUsers: true
 `
 		}
+		if id == "z1111" {
+			yaml += `
+    Login:
+      LoginCluster: z1111
+      OpenIDConnect:
+        Enable: true
+        Issuer: ` + s.oidcprovider.Issuer.URL + `
+        ClientID: ` + s.oidcprovider.ValidClientID + `
+        ClientSecret: ` + s.oidcprovider.ValidClientSecret + `
+        EmailClaim: email
+        EmailVerifiedClaim: email_verified
+`
+		} else {
+			yaml += `
+    Login:
+      LoginCluster: z1111
+`
+		}
 
 		loader := config.NewLoader(bytes.NewBufferString(yaml), ctxlog.TestLogger(c))
 		loader.Path = "-"
@@ -520,3 +549,55 @@ func (s *IntegrationSuite) TestSetupUserWithVM(c *check.C) {
 
 	c.Check(len(outLinks.Items), check.Equals, 1)
 }
+
+func (s *IntegrationSuite) TestOIDCAccessTokenAuth(c *check.C) {
+	conn1 := s.conn("z1111")
+	rootctx1, _, _ := s.rootClients("z1111")
+	s.userClients(rootctx1, c, conn1, "z1111", true)
+
+	accesstoken := s.oidcprovider.ValidAccessToken()
+
+	for _, clusterid := range []string{"z1111", "z2222"} {
+		c.Logf("trying clusterid %s", clusterid)
+
+		conn := s.conn(clusterid)
+		ctx, ac, kc := s.clientsWithToken(clusterid, accesstoken)
+
+		var coll arvados.Collection
+
+		// Write some file data and create a collection
+		{
+			fs, err := coll.FileSystem(ac, kc)
+			c.Assert(err, check.IsNil)
+			f, err := fs.OpenFile("test.txt", os.O_CREATE|os.O_RDWR, 0777)
+			c.Assert(err, check.IsNil)
+			_, err = io.WriteString(f, "IntegrationSuite.TestOIDCAccessTokenAuth")
+			c.Assert(err, check.IsNil)
+			err = f.Close()
+			c.Assert(err, check.IsNil)
+			mtxt, err := fs.MarshalManifest(".")
+			c.Assert(err, check.IsNil)
+			coll, err = conn.CollectionCreate(ctx, arvados.CreateOptions{Attrs: map[string]interface{}{
+				"manifest_text": mtxt,
+			}})
+			c.Assert(err, check.IsNil)
+		}
+
+		// Read the collection & file data
+		{
+			user, err := conn.UserGetCurrent(ctx, arvados.GetOptions{})
+			c.Assert(err, check.IsNil)
+			c.Check(user.FullName, check.Equals, "Active User")
+			coll, err = conn.CollectionGet(ctx, arvados.GetOptions{UUID: coll.UUID})
+			c.Assert(err, check.IsNil)
+			c.Check(coll.ManifestText, check.Not(check.Equals), "")
+			fs, err := coll.FileSystem(ac, kc)
+			c.Assert(err, check.IsNil)
+			f, err := fs.Open("test.txt")
+			c.Assert(err, check.IsNil)
+			buf, err := ioutil.ReadAll(f)
+			c.Assert(err, check.IsNil)
+			c.Check(buf, check.DeepEquals, []byte("IntegrationSuite.TestOIDCAccessTokenAuth"))
+		}
+	}
+}

commit f1f8e6488da533641dc83f157df1313c352ee3fe
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Tue Sep 22 10:58:02 2020 -0400

    16669: Fix use of timestamp in local timezone.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/controller/localdb/login_oidc.go b/lib/controller/localdb/login_oidc.go
index 20f22b1b3..b74d22f8e 100644
--- a/lib/controller/localdb/login_oidc.go
+++ b/lib/controller/localdb/login_oidc.go
@@ -465,7 +465,7 @@ func (ta *oidcTokenAuthorizer) registerToken(ctx context.Context, tok string) er
 	// Expiry time for our token is one minute longer than our
 	// cache TTL, so we don't pass it through to RailsAPI just as
 	// it's expiring.
-	exp := time.Now().Add(tokenCacheTTL + time.Minute)
+	exp := time.Now().UTC().Add(tokenCacheTTL + time.Minute)
 
 	var aca arvados.APIClientAuthorization
 	if updating {

commit 8a164579625de9f1f8be73b4c02c3e5ae1bfdc36
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Wed Sep 16 11:24:05 2020 -0400

    16669: Fix nil pointer dereference.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/controller/localdb/login_oidc.go b/lib/controller/localdb/login_oidc.go
index 2eb212128..20f22b1b3 100644
--- a/lib/controller/localdb/login_oidc.go
+++ b/lib/controller/localdb/login_oidc.go
@@ -341,7 +341,9 @@ type oidcTokenAuthorizer struct {
 }
 
 func (ta *oidcTokenAuthorizer) Middleware(w http.ResponseWriter, r *http.Request, next http.Handler) {
-	if authhdr := strings.Split(r.Header.Get("Authorization"), " "); len(authhdr) > 1 && (authhdr[0] == "OAuth2" || authhdr[0] == "Bearer") {
+	if ta.ctrl == nil {
+		// Not using a compatible (OIDC) login controller.
+	} else if authhdr := strings.Split(r.Header.Get("Authorization"), " "); len(authhdr) > 1 && (authhdr[0] == "OAuth2" || authhdr[0] == "Bearer") {
 		err := ta.registerToken(r.Context(), authhdr[1])
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)

commit a3123a238eef6c3fc09434891284ef73e57cbf81
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Wed Sep 16 10:57:07 2020 -0400

    16669: Set expiry time when inserting new access token record.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/controller/localdb/login_oidc.go b/lib/controller/localdb/login_oidc.go
index 9188f0eed..2eb212128 100644
--- a/lib/controller/localdb/login_oidc.go
+++ b/lib/controller/localdb/login_oidc.go
@@ -460,11 +460,16 @@ func (ta *oidcTokenAuthorizer) registerToken(ctx context.Context, tok string) er
 		return err
 	}
 
+	// Expiry time for our token is one minute longer than our
+	// cache TTL, so we don't pass it through to RailsAPI just as
+	// it's expiring.
+	exp := time.Now().Add(tokenCacheTTL + time.Minute)
+
 	var aca arvados.APIClientAuthorization
 	if updating {
-		_, err = tx.ExecContext(ctx, `update api_client_authorizations set expires_at=$1 where api_token=$2`, time.Now().Add(tokenCacheTTL+time.Minute), hmac)
+		_, err = tx.ExecContext(ctx, `update api_client_authorizations set expires_at=$1 where api_token=$2`, exp, hmac)
 		if err != nil {
-			return fmt.Errorf("error adding OIDC access token to database: %w", err)
+			return fmt.Errorf("error updating token expiry time: %w", err)
 		}
 		ctxlog.FromContext(ctx).WithField("HMAC", hmac).Debug("(*oidcTokenAuthorizer)registerToken: updated api_client_authorizations row")
 	} else {
@@ -472,7 +477,7 @@ func (ta *oidcTokenAuthorizer) registerToken(ctx context.Context, tok string) er
 		if err != nil {
 			return err
 		}
-		_, err = tx.ExecContext(ctx, `update api_client_authorizations set api_token=$1 where uuid=$2`, hmac, aca.UUID)
+		_, err = tx.ExecContext(ctx, `update api_client_authorizations set api_token=$1, expires_at=$2 where uuid=$3`, hmac, exp, aca.UUID)
 		if err != nil {
 			return fmt.Errorf("error adding OIDC access token to database: %w", err)
 		}

commit 0dddb614432b0b7474b44b4a6f1b1ea7cd9c4e13
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Wed Sep 16 10:17:31 2020 -0400

    16669: Accept OIDC access token in RailsAPI auth.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/controller/localdb/login_oidc.go b/lib/controller/localdb/login_oidc.go
index 60de70b5d..9188f0eed 100644
--- a/lib/controller/localdb/login_oidc.go
+++ b/lib/controller/localdb/login_oidc.go
@@ -32,6 +32,7 @@ import (
 	"github.com/coreos/go-oidc"
 	lru "github.com/hashicorp/golang-lru"
 	"github.com/jmoiron/sqlx"
+	"github.com/sirupsen/logrus"
 	"golang.org/x/oauth2"
 	"google.golang.org/api/option"
 	"google.golang.org/api/people/v1"
@@ -341,12 +342,11 @@ type oidcTokenAuthorizer struct {
 
 func (ta *oidcTokenAuthorizer) Middleware(w http.ResponseWriter, r *http.Request, next http.Handler) {
 	if authhdr := strings.Split(r.Header.Get("Authorization"), " "); len(authhdr) > 1 && (authhdr[0] == "OAuth2" || authhdr[0] == "Bearer") {
-		tok, err := ta.exchangeToken(r.Context(), authhdr[1])
+		err := ta.registerToken(r.Context(), authhdr[1])
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return
 		}
-		r.Header.Set("Authorization", "Bearer "+tok)
 	}
 	next.ServeHTTP(w, r)
 }
@@ -364,22 +364,22 @@ func (ta *oidcTokenAuthorizer) WrapCalls(origFunc api.RoutableFunc) api.Routable
 		// Check each token in the incoming request. If any
 		// are OAuth2 access tokens, swap them out for Arvados
 		// tokens.
-		for tokidx, tok := range creds.Tokens {
-			tok, err = ta.exchangeToken(ctx, tok)
+		for _, tok := range creds.Tokens {
+			err = ta.registerToken(ctx, tok)
 			if err != nil {
 				return nil, err
 			}
-			creds.Tokens[tokidx] = tok
 		}
-		ctxlog.FromContext(ctx).WithField("creds", creds).Debug("(*oidcTokenAuthorizer)WrapCalls: new creds")
-		ctx = auth.NewContext(ctx, creds)
 		return origFunc(ctx, opts)
 	}
 }
 
-func (ta *oidcTokenAuthorizer) exchangeToken(ctx context.Context, tok string) (string, error) {
+// registerToken checks whether tok is a valid OIDC Access Token and,
+// if so, ensures that an api_client_authorizations row exists so that
+// RailsAPI will accept it as an Arvados token.
+func (ta *oidcTokenAuthorizer) registerToken(ctx context.Context, tok string) error {
 	if strings.HasPrefix(tok, "v2/") {
-		return tok, nil
+		return nil
 	}
 	if cached, hit := ta.cache.Get(tok); !hit {
 		// Fall through to database and OIDC provider checks
@@ -387,7 +387,7 @@ func (ta *oidcTokenAuthorizer) exchangeToken(ctx context.Context, tok string) (s
 	} else if exp, ok := cached.(time.Time); ok {
 		// cached negative result (value is expiry time)
 		if time.Now().Before(exp) {
-			return tok, nil
+			return nil
 		} else {
 			ta.cache.Remove(tok)
 		}
@@ -398,22 +398,22 @@ func (ta *oidcTokenAuthorizer) exchangeToken(ctx context.Context, tok string) (s
 		if aca.ExpiresAt != "" {
 			t, err := time.Parse(time.RFC3339Nano, aca.ExpiresAt)
 			if err != nil {
-				return "", fmt.Errorf("error parsing expires_at value: %w", err)
+				return fmt.Errorf("error parsing expires_at value: %w", err)
 			}
 			expiring = t.Before(time.Now().Add(time.Minute))
 		}
 		if !expiring {
-			return aca.TokenV2(), nil
+			return nil
 		}
 	}
 
 	db, err := ta.getdb(ctx)
 	if err != nil {
-		return "", err
+		return err
 	}
 	tx, err := db.Beginx()
 	if err != nil {
-		return "", err
+		return err
 	}
 	defer tx.Rollback()
 	ctx = ctrlctx.NewWithTransaction(ctx, tx)
@@ -428,13 +428,13 @@ func (ta *oidcTokenAuthorizer) exchangeToken(ctx context.Context, tok string) (s
 	var expiring bool
 	err = tx.QueryRowContext(ctx, `select (expires_at is not null and expires_at - interval '1 minute' <= current_timestamp at time zone 'UTC') from api_client_authorizations where api_token=$1`, hmac).Scan(&expiring)
 	if err != nil && err != sql.ErrNoRows {
-		return "", fmt.Errorf("database error while checking token: %w", err)
+		return fmt.Errorf("database error while checking token: %w", err)
 	} else if err == nil && !expiring {
 		// Token is already in the database as an Arvados
 		// token, and isn't about to expire, so we can pass it
 		// through to RailsAPI etc. regardless of whether it's
 		// an OIDC access token.
-		return tok, nil
+		return nil
 	}
 	updating := err == nil
 
@@ -444,7 +444,7 @@ func (ta *oidcTokenAuthorizer) exchangeToken(ctx context.Context, tok string) (s
 	// server components will accept.
 	err = ta.ctrl.setup()
 	if err != nil {
-		return "", fmt.Errorf("error setting up OpenID Connect provider: %s", err)
+		return fmt.Errorf("error setting up OpenID Connect provider: %s", err)
 	}
 	oauth2Token := &oauth2.Token{
 		AccessToken: tok,
@@ -452,35 +452,37 @@ func (ta *oidcTokenAuthorizer) exchangeToken(ctx context.Context, tok string) (s
 	userinfo, err := ta.ctrl.provider.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token))
 	if err != nil {
 		ta.cache.Add(tok, time.Now().Add(tokenCacheNegativeTTL))
-		return tok, nil
+		return nil
 	}
-	ctxlog.FromContext(ctx).WithField("userinfo", userinfo).Debug("(*oidcTokenAuthorizer)exchangeToken: got userinfo")
+	ctxlog.FromContext(ctx).WithField("userinfo", userinfo).Debug("(*oidcTokenAuthorizer)registerToken: got userinfo")
 	authinfo, err := ta.ctrl.getAuthInfo(ctx, oauth2Token, userinfo)
 	if err != nil {
-		return "", err
+		return err
 	}
 
 	var aca arvados.APIClientAuthorization
 	if updating {
 		_, err = tx.ExecContext(ctx, `update api_client_authorizations set expires_at=$1 where api_token=$2`, time.Now().Add(tokenCacheTTL+time.Minute), hmac)
 		if err != nil {
-			return "", fmt.Errorf("error adding OIDC access token to database: %w", err)
+			return fmt.Errorf("error adding OIDC access token to database: %w", err)
 		}
+		ctxlog.FromContext(ctx).WithField("HMAC", hmac).Debug("(*oidcTokenAuthorizer)registerToken: updated api_client_authorizations row")
 	} else {
 		aca, err = createAPIClientAuthorization(ctx, ta.ctrl.RailsProxy, ta.ctrl.Cluster.SystemRootToken, *authinfo)
 		if err != nil {
-			return "", err
+			return err
 		}
 		_, err = tx.ExecContext(ctx, `update api_client_authorizations set api_token=$1 where uuid=$2`, hmac, aca.UUID)
 		if err != nil {
-			return "", fmt.Errorf("error adding OIDC access token to database: %w", err)
+			return fmt.Errorf("error adding OIDC access token to database: %w", err)
 		}
 		aca.APIToken = hmac
+		ctxlog.FromContext(ctx).WithFields(logrus.Fields{"UUID": aca.UUID, "HMAC": hmac}).Debug("(*oidcTokenAuthorizer)registerToken: inserted api_client_authorizations row")
 	}
 	err = tx.Commit()
 	if err != nil {
-		return "", err
+		return err
 	}
 	ta.cache.Add(tok, aca)
-	return aca.TokenV2(), nil
+	return nil
 }
diff --git a/services/api/app/middlewares/arvados_api_token.rb b/services/api/app/middlewares/arvados_api_token.rb
index acdc48581..be4e8bb0b 100644
--- a/services/api/app/middlewares/arvados_api_token.rb
+++ b/services/api/app/middlewares/arvados_api_token.rb
@@ -43,7 +43,7 @@ class ArvadosApiToken
     auth = nil
     [params["api_token"],
      params["oauth_token"],
-     env["HTTP_AUTHORIZATION"].andand.match(/(OAuth2|Bearer) ([-\/a-zA-Z0-9]+)/).andand[2],
+     env["HTTP_AUTHORIZATION"].andand.match(/(OAuth2|Bearer) ([!-~]+)/).andand[2],
      *reader_tokens,
     ].each do |supplied|
       next if !supplied
diff --git a/services/api/app/models/api_client_authorization.rb b/services/api/app/models/api_client_authorization.rb
index 868405f04..6a34ed955 100644
--- a/services/api/app/models/api_client_authorization.rb
+++ b/services/api/app/models/api_client_authorization.rb
@@ -186,17 +186,28 @@ class ApiClientAuthorization < ArvadosModel
       end
 
     else
-      # token is not a 'v2' token
+      # token is not a 'v2' token. It could be just the secret part
+      # ("v1 token") -- or it could be an OpenIDConnect access token,
+      # in which case either (a) the controller will have inserted a
+      # row with api_token = hmac(systemroottoken,oidctoken) before
+      # forwarding it, or (b) we'll have done that ourselves, or (c)
+      # we'll need to ask LoginCluster to validate it for us below,
+      # and then insert a local row for a faster lookup next time.
+      hmac = OpenSSL::HMAC.hexdigest('sha256', Rails.configuration.SystemRootToken, token)
       auth = ApiClientAuthorization.
                includes(:user, :api_client).
-               where('api_token=? and (expires_at is null or expires_at > CURRENT_TIMESTAMP)', token).
+               where('api_token in (?, ?) and (expires_at is null or expires_at > CURRENT_TIMESTAMP)', token, hmac).
                first
       if auth && auth.user
         return auth
-      elsif Rails.configuration.Login.LoginCluster && Rails.configuration.Login.LoginCluster != Rails.configuration.ClusterID
+      elsif !Rails.configuration.Login.LoginCluster.blank? && Rails.configuration.Login.LoginCluster != Rails.configuration.ClusterID
         # An unrecognized non-v2 token might be an OIDC Access Token
-        # that can be verified by our login cluster in the code below.
+        # that can be verified by our login cluster in the code
+        # below. If so, we'll stuff the database with hmac instead of
+        # the real OIDC token.
         upstream_cluster_id = Rails.configuration.Login.LoginCluster
+        token_uuid = generate_uuid
+        secret = hmac
       else
         return nil
       end

commit 8795c42cfcd79dd0608e1ba580e5f8f5870851b4
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Tue Sep 15 10:40:05 2020 -0400

    16669: Give LoginCluster a chance to validate bare (non-v2) tokens.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/services/api/app/models/api_client_authorization.rb b/services/api/app/models/api_client_authorization.rb
index 37ad31feb..868405f04 100644
--- a/services/api/app/models/api_client_authorization.rb
+++ b/services/api/app/models/api_client_authorization.rb
@@ -128,6 +128,10 @@ class ApiClientAuthorization < ArvadosModel
       return auth
     end
 
+    token_uuid = ''
+    secret = token
+    optional = nil
+
     case token[0..2]
     when 'v2/'
       _, token_uuid, secret, optional = token.split('/')
@@ -170,148 +174,155 @@ class ApiClientAuthorization < ArvadosModel
         return auth
       end
 
-      token_uuid_prefix = token_uuid[0..4]
-      if token_uuid_prefix == Rails.configuration.ClusterID
+      upstream_cluster_id = token_uuid[0..4]
+      if upstream_cluster_id == Rails.configuration.ClusterID
         # Token is supposedly issued by local cluster, but if the
         # token were valid, we would have been found in the database
         # in the above query.
         return nil
-      elsif token_uuid_prefix.length != 5
+      elsif upstream_cluster_id.length != 5
         # malformed
         return nil
       end
 
-      # Invariant: token_uuid_prefix != Rails.configuration.ClusterID
-      #
-      # In other words the remaing code in this method below is the
-      # case that determines whether to accept a token that was issued
-      # by a remote cluster when the token absent or expired in our
-      # database.  To begin, we need to ask the cluster that issued
-      # the token to [re]validate it.
-      clnt = ApiClientAuthorization.make_http_client(uuid_prefix: token_uuid_prefix)
-
-      host = remote_host(uuid_prefix: token_uuid_prefix)
-      if !host
-        Rails.logger.warn "remote authentication rejected: no host for #{token_uuid_prefix.inspect}"
+    else
+      # token is not a 'v2' token
+      auth = ApiClientAuthorization.
+               includes(:user, :api_client).
+               where('api_token=? and (expires_at is null or expires_at > CURRENT_TIMESTAMP)', token).
+               first
+      if auth && auth.user
+        return auth
+      elsif Rails.configuration.Login.LoginCluster && Rails.configuration.Login.LoginCluster != Rails.configuration.ClusterID
+        # An unrecognized non-v2 token might be an OIDC Access Token
+        # that can be verified by our login cluster in the code below.
+        upstream_cluster_id = Rails.configuration.Login.LoginCluster
+      else
         return nil
       end
+    end
 
-      begin
-        remote_user = SafeJSON.load(
-          clnt.get_content('https://' + host + '/arvados/v1/users/current',
-                           {'remote' => Rails.configuration.ClusterID},
-                           {'Authorization' => 'Bearer ' + token}))
-      rescue => e
-        Rails.logger.warn "remote authentication with token #{token.inspect} failed: #{e}"
-        return nil
-      end
+    # Invariant: upstream_cluster_id != Rails.configuration.ClusterID
+    #
+    # In other words the remaining code in this method decides
+    # whether to accept a token that was issued by a remote cluster
+    # when the token is absent or expired in our database.  To
+    # begin, we need to ask the cluster that issued the token to
+    # [re]validate it.
+    clnt = ApiClientAuthorization.make_http_client(uuid_prefix: upstream_cluster_id)
+
+    host = remote_host(uuid_prefix: upstream_cluster_id)
+    if !host
+      Rails.logger.warn "remote authentication rejected: no host for #{upstream_cluster_id.inspect}"
+      return nil
+    end
 
-      # Check the response is well formed.
-      if !remote_user.is_a?(Hash) || !remote_user['uuid'].is_a?(String)
-        Rails.logger.warn "remote authentication rejected: remote_user=#{remote_user.inspect}"
-        return nil
-      end
+    begin
+      remote_user = SafeJSON.load(
+        clnt.get_content('https://' + host + '/arvados/v1/users/current',
+                         {'remote' => Rails.configuration.ClusterID},
+                         {'Authorization' => 'Bearer ' + token}))
+    rescue => e
+      Rails.logger.warn "remote authentication with token #{token.inspect} failed: #{e}"
+      return nil
+    end
 
-      remote_user_prefix = remote_user['uuid'][0..4]
+    # Check the response is well formed.
+    if !remote_user.is_a?(Hash) || !remote_user['uuid'].is_a?(String)
+      Rails.logger.warn "remote authentication rejected: remote_user=#{remote_user.inspect}"
+      return nil
+    end
 
-      # Clusters can only authenticate for their own users.
-      if remote_user_prefix != token_uuid_prefix
-        Rails.logger.warn "remote authentication rejected: claimed remote user #{remote_user_prefix} but token was issued by #{token_uuid_prefix}"
-        return nil
-      end
+    remote_user_prefix = remote_user['uuid'][0..4]
 
-      # Invariant:    remote_user_prefix == token_uuid_prefix
-      # therefore:    remote_user_prefix != Rails.configuration.ClusterID
+    # Clusters can only authenticate for their own users.
+    if remote_user_prefix != upstream_cluster_id
+      Rails.logger.warn "remote authentication rejected: claimed remote user #{remote_user_prefix} but token was issued by #{upstream_cluster_id}"
+      return nil
+    end
 
-      # Add or update user and token in local database so we can
-      # validate subsequent requests faster.
+    # Invariant:    remote_user_prefix == upstream_cluster_id
+    # therefore:    remote_user_prefix != Rails.configuration.ClusterID
 
-      if remote_user['uuid'][-22..-1] == '-tpzed-anonymouspublic'
-        # Special case: map the remote anonymous user to local anonymous user
-        remote_user['uuid'] = anonymous_user_uuid
-      end
+    # Add or update user and token in local database so we can
+    # validate subsequent requests faster.
 
-      user = User.find_by_uuid(remote_user['uuid'])
+    if remote_user['uuid'][-22..-1] == '-tpzed-anonymouspublic'
+      # Special case: map the remote anonymous user to local anonymous user
+      remote_user['uuid'] = anonymous_user_uuid
+    end
 
-      if !user
-        # Create a new record for this user.
-        user = User.new(uuid: remote_user['uuid'],
-                        is_active: false,
-                        is_admin: false,
-                        email: remote_user['email'],
-                        owner_uuid: system_user_uuid)
-        user.set_initial_username(requested: remote_user['username'])
-      end
+    user = User.find_by_uuid(remote_user['uuid'])
 
-      # Sync user record.
-      act_as_system_user do
-        %w[first_name last_name email prefs].each do |attr|
-          user.send(attr+'=', remote_user[attr])
-        end
+    if !user
+      # Create a new record for this user.
+      user = User.new(uuid: remote_user['uuid'],
+                      is_active: false,
+                      is_admin: false,
+                      email: remote_user['email'],
+                      owner_uuid: system_user_uuid)
+      user.set_initial_username(requested: remote_user['username'])
+    end
 
-        if remote_user['uuid'][-22..-1] == '-tpzed-000000000000000'
-          user.first_name = "root"
-          user.last_name = "from cluster #{remote_user_prefix}"
-        end
+    # Sync user record.
+    act_as_system_user do
+      %w[first_name last_name email prefs].each do |attr|
+        user.send(attr+'=', remote_user[attr])
+      end
 
-        user.save!
-
-        if user.is_invited && !remote_user['is_invited']
-          # Remote user is not "invited" state, they should be unsetup, which
-          # also makes them inactive.
-          user.unsetup
-        else
-          if !user.is_invited && remote_user['is_invited'] and
-            (remote_user_prefix == Rails.configuration.Login.LoginCluster or
-             Rails.configuration.Users.AutoSetupNewUsers or
-             Rails.configuration.Users.NewUsersAreActive or
-             Rails.configuration.RemoteClusters[remote_user_prefix].andand["ActivateUsers"])
-            user.setup
-          end
+      if remote_user['uuid'][-22..-1] == '-tpzed-000000000000000'
+        user.first_name = "root"
+        user.last_name = "from cluster #{remote_user_prefix}"
+      end
 
-          if !user.is_active && remote_user['is_active'] && user.is_invited and
-            (remote_user_prefix == Rails.configuration.Login.LoginCluster or
-             Rails.configuration.Users.NewUsersAreActive or
-             Rails.configuration.RemoteClusters[remote_user_prefix].andand["ActivateUsers"])
-            user.update_attributes!(is_active: true)
-          elsif user.is_active && !remote_user['is_active']
-            user.update_attributes!(is_active: false)
-          end
+      user.save!
+
+      if user.is_invited && !remote_user['is_invited']
+        # Remote user is not "invited" state, they should be unsetup, which
+        # also makes them inactive.
+        user.unsetup
+      else
+        if !user.is_invited && remote_user['is_invited'] and
+          (remote_user_prefix == Rails.configuration.Login.LoginCluster or
+           Rails.configuration.Users.AutoSetupNewUsers or
+           Rails.configuration.Users.NewUsersAreActive or
+           Rails.configuration.RemoteClusters[remote_user_prefix].andand["ActivateUsers"])
+          user.setup
+        end
 
-          if remote_user_prefix == Rails.configuration.Login.LoginCluster and
-            user.is_active and
-            user.is_admin != remote_user['is_admin']
-            # Remote cluster controls our user database, including the
-            # admin flag.
-            user.update_attributes!(is_admin: remote_user['is_admin'])
-          end
+        if !user.is_active && remote_user['is_active'] && user.is_invited and
+          (remote_user_prefix == Rails.configuration.Login.LoginCluster or
+           Rails.configuration.Users.NewUsersAreActive or
+           Rails.configuration.RemoteClusters[remote_user_prefix].andand["ActivateUsers"])
+          user.update_attributes!(is_active: true)
+        elsif user.is_active && !remote_user['is_active']
+          user.update_attributes!(is_active: false)
         end
 
-        # We will accept this token (and avoid reloading the user
-        # record) for 'RemoteTokenRefresh' (default 5 minutes).
-        # Possible todo:
-        # Request the actual api_client_auth record from the remote
-        # server in case it wants the token to expire sooner.
-        auth = ApiClientAuthorization.find_or_create_by(uuid: token_uuid) do |auth|
-          auth.user = user
-          auth.api_client_id = 0
+        if remote_user_prefix == Rails.configuration.Login.LoginCluster and
+          user.is_active and
+          user.is_admin != remote_user['is_admin']
+          # Remote cluster controls our user database, including the
+          # admin flag.
+          user.update_attributes!(is_admin: remote_user['is_admin'])
         end
-        auth.update_attributes!(user: user,
-                                api_token: secret,
-                                api_client_id: 0,
-                                expires_at: Time.now + Rails.configuration.Login.RemoteTokenRefresh)
-        Rails.logger.debug "cached remote token #{token_uuid} with secret #{secret} in local db"
       end
-      return auth
-    else
-      # token is not a 'v2' token
-      auth = ApiClientAuthorization.
-               includes(:user, :api_client).
-               where('api_token=? and (expires_at is null or expires_at > CURRENT_TIMESTAMP)', token).
-               first
-      if auth && auth.user
-        return auth
+
+      # We will accept this token (and avoid reloading the user
+      # record) for 'RemoteTokenRefresh' (default 5 minutes).
+      # Possible todo:
+      # Request the actual api_client_auth record from the remote
+      # server in case it wants the token to expire sooner.
+      auth = ApiClientAuthorization.find_or_create_by(uuid: token_uuid) do |auth|
+        auth.user = user
+        auth.api_client_id = 0
       end
+      auth.update_attributes!(user: user,
+                              api_token: secret,
+                              api_client_id: 0,
+                              expires_at: Time.now + Rails.configuration.Login.RemoteTokenRefresh)
+      Rails.logger.debug "cached remote token #{token_uuid} with secret #{secret} in local db"
+      return auth
     end
 
     return nil

commit 24223057a8dd3a03f1c6457287cb12167c6b67ee
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Fri Sep 4 11:20:29 2020 -0400

    16669: Accept OIDC access token in lieu of arvados api token.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/controller/api/routable.go b/lib/controller/api/routable.go
index 6049cba8e..3003ea2df 100644
--- a/lib/controller/api/routable.go
+++ b/lib/controller/api/routable.go
@@ -15,3 +15,16 @@ import "context"
 // it to the router package would cause a circular dependency
 // router->arvadostest->ctrlctx->router.)
 type RoutableFunc func(ctx context.Context, opts interface{}) (interface{}, error)
+
+type RoutableFuncWrapper func(RoutableFunc) RoutableFunc
+
+// ComposeWrappers(w1, w2, w3, ...) returns a RoutableFuncWrapper that
+// composes w1, w2, w3, ... such that w1 is the outermost wrapper.
+func ComposeWrappers(wraps ...RoutableFuncWrapper) RoutableFuncWrapper {
+	return func(f RoutableFunc) RoutableFunc {
+		for i := len(wraps) - 1; i >= 0; i-- {
+			f = wraps[i](f)
+		}
+		return f
+	}
+}
diff --git a/lib/controller/auth_test.go b/lib/controller/auth_test.go
new file mode 100644
index 000000000..a188c3082
--- /dev/null
+++ b/lib/controller/auth_test.go
@@ -0,0 +1,118 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+package controller
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"time"
+
+	"git.arvados.org/arvados.git/sdk/go/arvados"
+	"git.arvados.org/arvados.git/sdk/go/arvadostest"
+	"git.arvados.org/arvados.git/sdk/go/ctxlog"
+	"git.arvados.org/arvados.git/sdk/go/httpserver"
+	"github.com/sirupsen/logrus"
+	check "gopkg.in/check.v1"
+)
+
+// Gocheck boilerplate
+var _ = check.Suite(&AuthSuite{})
+
+type AuthSuite struct {
+	log logrus.FieldLogger
+	// testServer and testHandler are the controller being tested,
+	// "zhome".
+	testServer  *httpserver.Server
+	testHandler *Handler
+	// remoteServer ("zzzzz") forwards requests to the Rails API
+	// provided by the integration test environment.
+	remoteServer *httpserver.Server
+	// remoteMock ("zmock") appends each incoming request to
+	// remoteMockRequests, and returns 200 with an empty JSON
+	// object.
+	remoteMock         *httpserver.Server
+	remoteMockRequests []http.Request
+
+	fakeProvider *arvadostest.OIDCProvider
+}
+
+func (s *AuthSuite) SetUpTest(c *check.C) {
+	s.log = ctxlog.TestLogger(c)
+
+	s.remoteServer = newServerFromIntegrationTestEnv(c)
+	c.Assert(s.remoteServer.Start(), check.IsNil)
+
+	s.remoteMock = newServerFromIntegrationTestEnv(c)
+	s.remoteMock.Server.Handler = http.HandlerFunc(http.NotFound)
+	c.Assert(s.remoteMock.Start(), check.IsNil)
+
+	s.fakeProvider = arvadostest.NewOIDCProvider(c)
+	s.fakeProvider.AuthEmail = "active-user at arvados.local"
+	s.fakeProvider.AuthEmailVerified = true
+	s.fakeProvider.AuthName = "Fake User Name"
+	s.fakeProvider.ValidCode = fmt.Sprintf("abcdefgh-%d", time.Now().Unix())
+	s.fakeProvider.PeopleAPIResponse = map[string]interface{}{}
+	s.fakeProvider.ValidClientID = "test%client$id"
+	s.fakeProvider.ValidClientSecret = "test#client/secret"
+
+	cluster := &arvados.Cluster{
+		ClusterID:        "zhome",
+		PostgreSQL:       integrationTestCluster().PostgreSQL,
+		ForceLegacyAPI14: forceLegacyAPI14,
+		SystemRootToken:  arvadostest.SystemRootToken,
+	}
+	cluster.TLS.Insecure = true
+	cluster.API.MaxItemsPerResponse = 1000
+	cluster.API.MaxRequestAmplification = 4
+	cluster.API.RequestTimeout = arvados.Duration(5 * time.Minute)
+	arvadostest.SetServiceURL(&cluster.Services.RailsAPI, "https://"+os.Getenv("ARVADOS_TEST_API_HOST"))
+	arvadostest.SetServiceURL(&cluster.Services.Controller, "http://localhost/")
+
+	cluster.RemoteClusters = map[string]arvados.RemoteCluster{
+		"zzzzz": {
+			Host:   s.remoteServer.Addr,
+			Proxy:  true,
+			Scheme: "http",
+		},
+		"zmock": {
+			Host:   s.remoteMock.Addr,
+			Proxy:  true,
+			Scheme: "http",
+		},
+		"*": {
+			Scheme: "https",
+		},
+	}
+	cluster.Login.OpenIDConnect.Enable = true
+	cluster.Login.OpenIDConnect.Issuer = s.fakeProvider.Issuer.URL
+	cluster.Login.OpenIDConnect.ClientID = s.fakeProvider.ValidClientID
+	cluster.Login.OpenIDConnect.ClientSecret = s.fakeProvider.ValidClientSecret
+	cluster.Login.OpenIDConnect.EmailClaim = "email"
+	cluster.Login.OpenIDConnect.EmailVerifiedClaim = "email_verified"
+
+	s.testHandler = &Handler{Cluster: cluster}
+	s.testServer = newServerFromIntegrationTestEnv(c)
+	s.testServer.Server.Handler = httpserver.HandlerWithContext(
+		ctxlog.Context(context.Background(), s.log),
+		httpserver.AddRequestIDs(httpserver.LogRequests(s.testHandler)))
+	c.Assert(s.testServer.Start(), check.IsNil)
+}
+
+func (s *AuthSuite) TestLocalOIDCAccessToken(c *check.C) {
+	req := httptest.NewRequest("GET", "/arvados/v1/users/current", nil)
+	req.Header.Set("Authorization", "Bearer "+s.fakeProvider.ValidAccessToken())
+	rr := httptest.NewRecorder()
+	s.testServer.Server.Handler.ServeHTTP(rr, req)
+	resp := rr.Result()
+	c.Check(resp.StatusCode, check.Equals, http.StatusOK)
+	var u arvados.User
+	c.Check(json.NewDecoder(resp.Body).Decode(&u), check.IsNil)
+	c.Check(u.UUID, check.Equals, arvadostest.ActiveUserUUID)
+	c.Check(u.OwnerUUID, check.Equals, "zzzzz-tpzed-000000000000000")
+}
diff --git a/lib/controller/handler.go b/lib/controller/handler.go
index 2dd1d816e..25bba558d 100644
--- a/lib/controller/handler.go
+++ b/lib/controller/handler.go
@@ -14,7 +14,9 @@ import (
 	"sync"
 	"time"
 
+	"git.arvados.org/arvados.git/lib/controller/api"
 	"git.arvados.org/arvados.git/lib/controller/federation"
+	"git.arvados.org/arvados.git/lib/controller/localdb"
 	"git.arvados.org/arvados.git/lib/controller/railsproxy"
 	"git.arvados.org/arvados.git/lib/controller/router"
 	"git.arvados.org/arvados.git/lib/ctrlctx"
@@ -87,7 +89,8 @@ func (h *Handler) setup() {
 		Routes: health.Routes{"ping": func() error { _, err := h.db(context.TODO()); return err }},
 	})
 
-	rtr := router.New(federation.New(h.Cluster), ctrlctx.WrapCallsInTransactions(h.db))
+	oidcAuthorizer := localdb.OIDCAccessTokenAuthorizer(h.Cluster, h.db)
+	rtr := router.New(federation.New(h.Cluster), api.ComposeWrappers(ctrlctx.WrapCallsInTransactions(h.db), oidcAuthorizer.WrapCalls))
 	mux.Handle("/arvados/v1/config", rtr)
 	mux.Handle("/"+arvados.EndpointUserAuthenticate.Path, rtr)
 
@@ -103,6 +106,7 @@ func (h *Handler) setup() {
 	hs := http.NotFoundHandler()
 	hs = prepend(hs, h.proxyRailsAPI)
 	hs = h.setupProxyRemoteCluster(hs)
+	hs = prepend(hs, oidcAuthorizer.Middleware)
 	mux.Handle("/", hs)
 	h.handlerStack = mux
 
diff --git a/lib/controller/localdb/login_oidc.go b/lib/controller/localdb/login_oidc.go
index e0b01f13e..60de70b5d 100644
--- a/lib/controller/localdb/login_oidc.go
+++ b/lib/controller/localdb/login_oidc.go
@@ -9,9 +9,11 @@ import (
 	"context"
 	"crypto/hmac"
 	"crypto/sha256"
+	"database/sql"
 	"encoding/base64"
 	"errors"
 	"fmt"
+	"io"
 	"net/http"
 	"net/url"
 	"strings"
@@ -19,17 +21,28 @@ import (
 	"text/template"
 	"time"
 
+	"git.arvados.org/arvados.git/lib/controller/api"
+	"git.arvados.org/arvados.git/lib/controller/railsproxy"
 	"git.arvados.org/arvados.git/lib/controller/rpc"
+	"git.arvados.org/arvados.git/lib/ctrlctx"
 	"git.arvados.org/arvados.git/sdk/go/arvados"
 	"git.arvados.org/arvados.git/sdk/go/auth"
 	"git.arvados.org/arvados.git/sdk/go/ctxlog"
 	"git.arvados.org/arvados.git/sdk/go/httpserver"
 	"github.com/coreos/go-oidc"
+	lru "github.com/hashicorp/golang-lru"
+	"github.com/jmoiron/sqlx"
 	"golang.org/x/oauth2"
 	"google.golang.org/api/option"
 	"google.golang.org/api/people/v1"
 )
 
+const (
+	tokenCacheSize        = 1000
+	tokenCacheNegativeTTL = time.Minute * 5
+	tokenCacheTTL         = time.Minute * 10
+)
+
 type oidcLoginController struct {
 	Cluster            *arvados.Cluster
 	RailsProxy         *railsProxy
@@ -139,17 +152,23 @@ func (ctrl *oidcLoginController) UserAuthenticate(ctx context.Context, opts arva
 	return arvados.APIClientAuthorization{}, httpserver.ErrorWithStatus(errors.New("username/password authentication is not available"), http.StatusBadRequest)
 }
 
+// claimser can decode arbitrary claims into a map. Implemented by
+// *oauth2.IDToken and *oauth2.UserInfo.
+type claimser interface {
+	Claims(interface{}) error
+}
+
 // Use a person's token to get all of their email addresses, with the
 // primary address at index 0. The provided defaultAddr is always
 // included in the returned slice, and is used as the primary if the
 // Google API does not indicate one.
-func (ctrl *oidcLoginController) getAuthInfo(ctx context.Context, token *oauth2.Token, idToken *oidc.IDToken) (*rpc.UserSessionAuthInfo, error) {
+func (ctrl *oidcLoginController) getAuthInfo(ctx context.Context, token *oauth2.Token, claimser claimser) (*rpc.UserSessionAuthInfo, error) {
 	var ret rpc.UserSessionAuthInfo
 	defer ctxlog.FromContext(ctx).WithField("ret", &ret).Debug("getAuthInfo returned")
 
 	var claims map[string]interface{}
-	if err := idToken.Claims(&claims); err != nil {
-		return nil, fmt.Errorf("error extracting claims from ID token: %s", err)
+	if err := claimser.Claims(&claims); err != nil {
+		return nil, fmt.Errorf("error extracting claims from token: %s", err)
 	} else if verified, _ := claims[ctrl.EmailVerifiedClaim].(bool); verified || ctrl.EmailVerifiedClaim == "" {
 		// Fall back to this info if the People API call
 		// (below) doesn't return a primary && verified email.
@@ -297,3 +316,171 @@ func (s oauth2State) computeHMAC(key []byte) []byte {
 	fmt.Fprintf(mac, "%x %s %s", s.Time, s.Remote, s.ReturnTo)
 	return mac.Sum(nil)
 }
+
+func OIDCAccessTokenAuthorizer(cluster *arvados.Cluster, getdb func(context.Context) (*sqlx.DB, error)) *oidcTokenAuthorizer {
+	// We want ctrl to be nil if the chosen controller is not a
+	// *oidcLoginController, so we can ignore the 2nd return value
+	// of this type cast.
+	ctrl, _ := chooseLoginController(cluster, railsproxy.NewConn(cluster)).(*oidcLoginController)
+	cache, err := lru.New2Q(tokenCacheSize)
+	if err != nil {
+		panic(err)
+	}
+	return &oidcTokenAuthorizer{
+		ctrl:  ctrl,
+		getdb: getdb,
+		cache: cache,
+	}
+}
+
+type oidcTokenAuthorizer struct {
+	ctrl  *oidcLoginController
+	getdb func(context.Context) (*sqlx.DB, error)
+	cache *lru.TwoQueueCache
+}
+
+func (ta *oidcTokenAuthorizer) Middleware(w http.ResponseWriter, r *http.Request, next http.Handler) {
+	if authhdr := strings.Split(r.Header.Get("Authorization"), " "); len(authhdr) > 1 && (authhdr[0] == "OAuth2" || authhdr[0] == "Bearer") {
+		tok, err := ta.exchangeToken(r.Context(), authhdr[1])
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		r.Header.Set("Authorization", "Bearer "+tok)
+	}
+	next.ServeHTTP(w, r)
+}
+
+func (ta *oidcTokenAuthorizer) WrapCalls(origFunc api.RoutableFunc) api.RoutableFunc {
+	if ta.ctrl == nil {
+		// Not using a compatible (OIDC) login controller.
+		return origFunc
+	}
+	return func(ctx context.Context, opts interface{}) (_ interface{}, err error) {
+		creds, ok := auth.FromContext(ctx)
+		if !ok {
+			return origFunc(ctx, opts)
+		}
+		// Check each token in the incoming request. If any
+		// are OAuth2 access tokens, swap them out for Arvados
+		// tokens.
+		for tokidx, tok := range creds.Tokens {
+			tok, err = ta.exchangeToken(ctx, tok)
+			if err != nil {
+				return nil, err
+			}
+			creds.Tokens[tokidx] = tok
+		}
+		ctxlog.FromContext(ctx).WithField("creds", creds).Debug("(*oidcTokenAuthorizer)WrapCalls: new creds")
+		ctx = auth.NewContext(ctx, creds)
+		return origFunc(ctx, opts)
+	}
+}
+
+func (ta *oidcTokenAuthorizer) exchangeToken(ctx context.Context, tok string) (string, error) {
+	if strings.HasPrefix(tok, "v2/") {
+		return tok, nil
+	}
+	if cached, hit := ta.cache.Get(tok); !hit {
+		// Fall through to database and OIDC provider checks
+		// below
+	} else if exp, ok := cached.(time.Time); ok {
+		// cached negative result (value is expiry time)
+		if time.Now().Before(exp) {
+			return tok, nil
+		} else {
+			ta.cache.Remove(tok)
+		}
+	} else {
+		// cached positive result
+		aca := cached.(*arvados.APIClientAuthorization)
+		var expiring bool
+		if aca.ExpiresAt != "" {
+			t, err := time.Parse(time.RFC3339Nano, aca.ExpiresAt)
+			if err != nil {
+				return "", fmt.Errorf("error parsing expires_at value: %w", err)
+			}
+			expiring = t.Before(time.Now().Add(time.Minute))
+		}
+		if !expiring {
+			return aca.TokenV2(), nil
+		}
+	}
+
+	db, err := ta.getdb(ctx)
+	if err != nil {
+		return "", err
+	}
+	tx, err := db.Beginx()
+	if err != nil {
+		return "", err
+	}
+	defer tx.Rollback()
+	ctx = ctrlctx.NewWithTransaction(ctx, tx)
+
+	// We use hmac-sha256(accesstoken,systemroottoken) as the
+	// secret part of our own token, and avoid storing the auth
+	// provider's real secret in our database.
+	mac := hmac.New(sha256.New, []byte(ta.ctrl.Cluster.SystemRootToken))
+	io.WriteString(mac, tok)
+	hmac := fmt.Sprintf("%x", mac.Sum(nil))
+
+	var expiring bool
+	err = tx.QueryRowContext(ctx, `select (expires_at is not null and expires_at - interval '1 minute' <= current_timestamp at time zone 'UTC') from api_client_authorizations where api_token=$1`, hmac).Scan(&expiring)
+	if err != nil && err != sql.ErrNoRows {
+		return "", fmt.Errorf("database error while checking token: %w", err)
+	} else if err == nil && !expiring {
+		// Token is already in the database as an Arvados
+		// token, and isn't about to expire, so we can pass it
+		// through to RailsAPI etc. regardless of whether it's
+		// an OIDC access token.
+		return tok, nil
+	}
+	updating := err == nil
+
+	// Check whether the token is a valid OIDC access token. If
+	// so, swap it out for an Arvados token (creating/updating an
+	// api_client_authorizations row if needed) which downstream
+	// server components will accept.
+	err = ta.ctrl.setup()
+	if err != nil {
+		return "", fmt.Errorf("error setting up OpenID Connect provider: %s", err)
+	}
+	oauth2Token := &oauth2.Token{
+		AccessToken: tok,
+	}
+	userinfo, err := ta.ctrl.provider.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token))
+	if err != nil {
+		ta.cache.Add(tok, time.Now().Add(tokenCacheNegativeTTL))
+		return tok, nil
+	}
+	ctxlog.FromContext(ctx).WithField("userinfo", userinfo).Debug("(*oidcTokenAuthorizer)exchangeToken: got userinfo")
+	authinfo, err := ta.ctrl.getAuthInfo(ctx, oauth2Token, userinfo)
+	if err != nil {
+		return "", err
+	}
+
+	var aca arvados.APIClientAuthorization
+	if updating {
+		_, err = tx.ExecContext(ctx, `update api_client_authorizations set expires_at=$1 where api_token=$2`, time.Now().Add(tokenCacheTTL+time.Minute), hmac)
+		if err != nil {
+			return "", fmt.Errorf("error adding OIDC access token to database: %w", err)
+		}
+	} else {
+		aca, err = createAPIClientAuthorization(ctx, ta.ctrl.RailsProxy, ta.ctrl.Cluster.SystemRootToken, *authinfo)
+		if err != nil {
+			return "", err
+		}
+		_, err = tx.ExecContext(ctx, `update api_client_authorizations set api_token=$1 where uuid=$2`, hmac, aca.UUID)
+		if err != nil {
+			return "", fmt.Errorf("error adding OIDC access token to database: %w", err)
+		}
+		aca.APIToken = hmac
+	}
+	err = tx.Commit()
+	if err != nil {
+		return "", err
+	}
+	ta.cache.Add(tok, aca)
+	return aca.TokenV2(), nil
+}
diff --git a/sdk/go/arvadostest/oidc_provider.go b/sdk/go/arvadostest/oidc_provider.go
index 0632010ba..96205f919 100644
--- a/sdk/go/arvadostest/oidc_provider.go
+++ b/sdk/go/arvadostest/oidc_provider.go
@@ -47,6 +47,10 @@ func NewOIDCProvider(c *check.C) *OIDCProvider {
 	return p
 }
 
+func (p *OIDCProvider) ValidAccessToken() string {
+	return p.fakeToken([]byte("fake access token"))
+}
+
 func (p *OIDCProvider) serveOIDC(w http.ResponseWriter, req *http.Request) {
 	req.ParseForm()
 	p.c.Logf("serveOIDC: got req: %s %s %s", req.Method, req.URL, req.Form)
@@ -99,7 +103,7 @@ func (p *OIDCProvider) serveOIDC(w http.ResponseWriter, req *http.Request) {
 			ExpiresIn    int32  `json:"expires_in"`
 			IDToken      string `json:"id_token"`
 		}{
-			AccessToken:  p.fakeToken([]byte("fake access token")),
+			AccessToken:  p.ValidAccessToken(),
 			TokenType:    "Bearer",
 			RefreshToken: "test-refresh-token",
 			ExpiresIn:    30,
@@ -114,7 +118,20 @@ func (p *OIDCProvider) serveOIDC(w http.ResponseWriter, req *http.Request) {
 	case "/auth":
 		w.WriteHeader(http.StatusInternalServerError)
 	case "/userinfo":
-		w.WriteHeader(http.StatusInternalServerError)
+		if authhdr := req.Header.Get("Authorization"); strings.TrimPrefix(authhdr, "Bearer ") != p.ValidAccessToken() {
+			p.c.Logf("OIDCProvider: bad auth %q", authhdr)
+			w.WriteHeader(http.StatusUnauthorized)
+			return
+		}
+		json.NewEncoder(w).Encode(map[string]interface{}{
+			"sub":            "fake-user-id",
+			"name":           p.AuthName,
+			"given_name":     p.AuthName,
+			"family_name":    "",
+			"alt_username":   "desired-username",
+			"email":          p.AuthEmail,
+			"email_verified": p.AuthEmailVerified,
+		})
 	default:
 		w.WriteHeader(http.StatusNotFound)
 	}

commit 4312333bb8bd27e0b910b430edee91329124b02a
Author: Tom Clegg <tom at tomclegg.ca>
Date:   Mon Aug 31 09:21:43 2020 -0400

    16669: Move fake OIDC provider to arvadostest pkg.
    
    Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom at tomclegg.ca>

diff --git a/lib/controller/localdb/login_oidc_test.go b/lib/controller/localdb/login_oidc_test.go
index 2ccb1fce2..9bc6f90ea 100644
--- a/lib/controller/localdb/login_oidc_test.go
+++ b/lib/controller/localdb/login_oidc_test.go
@@ -7,9 +7,6 @@ package localdb
 import (
 	"bytes"
 	"context"
-	"crypto/rand"
-	"crypto/rsa"
-	"encoding/base64"
 	"encoding/json"
 	"fmt"
 	"net/http"
@@ -27,7 +24,6 @@ import (
 	"git.arvados.org/arvados.git/sdk/go/auth"
 	"git.arvados.org/arvados.git/sdk/go/ctxlog"
 	check "gopkg.in/check.v1"
-	jose "gopkg.in/square/go-jose.v2"
 )
 
 // Gocheck boilerplate
@@ -38,22 +34,10 @@ func Test(t *testing.T) {
 var _ = check.Suite(&OIDCLoginSuite{})
 
 type OIDCLoginSuite struct {
-	cluster               *arvados.Cluster
-	localdb               *Conn
-	railsSpy              *arvadostest.Proxy
-	fakeIssuer            *httptest.Server
-	fakePeopleAPI         *httptest.Server
-	fakePeopleAPIResponse map[string]interface{}
-	issuerKey             *rsa.PrivateKey
-
-	// expected token request
-	validCode         string
-	validClientID     string
-	validClientSecret string
-	// desired response from token endpoint
-	authEmail         string
-	authEmailVerified bool
-	authName          string
+	cluster      *arvados.Cluster
+	localdb      *Conn
+	railsSpy     *arvadostest.Proxy
+	fakeProvider *arvadostest.OIDCProvider
 }
 
 func (s *OIDCLoginSuite) TearDownSuite(c *check.C) {
@@ -64,103 +48,12 @@ func (s *OIDCLoginSuite) TearDownSuite(c *check.C) {
 }
 
 func (s *OIDCLoginSuite) SetUpTest(c *check.C) {
-	var err error
-	s.issuerKey, err = rsa.GenerateKey(rand.Reader, 2048)
-	c.Assert(err, check.IsNil)
-
-	s.authEmail = "active-user at arvados.local"
-	s.authEmailVerified = true
-	s.authName = "Fake User Name"
-	s.fakeIssuer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
-		req.ParseForm()
-		c.Logf("fakeIssuer: got req: %s %s %s", req.Method, req.URL, req.Form)
-		w.Header().Set("Content-Type", "application/json")
-		switch req.URL.Path {
-		case "/.well-known/openid-configuration":
-			json.NewEncoder(w).Encode(map[string]interface{}{
-				"issuer":                 s.fakeIssuer.URL,
-				"authorization_endpoint": s.fakeIssuer.URL + "/auth",
-				"token_endpoint":         s.fakeIssuer.URL + "/token",
-				"jwks_uri":               s.fakeIssuer.URL + "/jwks",
-				"userinfo_endpoint":      s.fakeIssuer.URL + "/userinfo",
-			})
-		case "/token":
-			var clientID, clientSecret string
-			auth, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(req.Header.Get("Authorization"), "Basic "))
-			authsplit := strings.Split(string(auth), ":")
-			if len(authsplit) == 2 {
-				clientID, _ = url.QueryUnescape(authsplit[0])
-				clientSecret, _ = url.QueryUnescape(authsplit[1])
-			}
-			if clientID != s.validClientID || clientSecret != s.validClientSecret {
-				c.Logf("fakeIssuer: expected (%q, %q) got (%q, %q)", s.validClientID, s.validClientSecret, clientID, clientSecret)
-				w.WriteHeader(http.StatusUnauthorized)
-				return
-			}
-
-			if req.Form.Get("code") != s.validCode || s.validCode == "" {
-				w.WriteHeader(http.StatusUnauthorized)
-				return
-			}
-			idToken, _ := json.Marshal(map[string]interface{}{
-				"iss":            s.fakeIssuer.URL,
-				"aud":            []string{clientID},
-				"sub":            "fake-user-id",
-				"exp":            time.Now().UTC().Add(time.Minute).Unix(),
-				"iat":            time.Now().UTC().Unix(),
-				"nonce":          "fake-nonce",
-				"email":          s.authEmail,
-				"email_verified": s.authEmailVerified,
-				"name":           s.authName,
-				"alt_verified":   true,                    // for custom claim tests
-				"alt_email":      "alt_email at example.com", // for custom claim tests
-				"alt_username":   "desired-username",      // for custom claim tests
-			})
-			json.NewEncoder(w).Encode(struct {
-				AccessToken  string `json:"access_token"`
-				TokenType    string `json:"token_type"`
-				RefreshToken string `json:"refresh_token"`
-				ExpiresIn    int32  `json:"expires_in"`
-				IDToken      string `json:"id_token"`
-			}{
-				AccessToken:  s.fakeToken(c, []byte("fake access token")),
-				TokenType:    "Bearer",
-				RefreshToken: "test-refresh-token",
-				ExpiresIn:    30,
-				IDToken:      s.fakeToken(c, idToken),
-			})
-		case "/jwks":
-			json.NewEncoder(w).Encode(jose.JSONWebKeySet{
-				Keys: []jose.JSONWebKey{
-					{Key: s.issuerKey.Public(), Algorithm: string(jose.RS256), KeyID: ""},
-				},
-			})
-		case "/auth":
-			w.WriteHeader(http.StatusInternalServerError)
-		case "/userinfo":
-			w.WriteHeader(http.StatusInternalServerError)
-		default:
-			w.WriteHeader(http.StatusNotFound)
-		}
-	}))
-	s.validCode = fmt.Sprintf("abcdefgh-%d", time.Now().Unix())
-
-	s.fakePeopleAPI = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
-		req.ParseForm()
-		c.Logf("fakePeopleAPI: got req: %s %s %s", req.Method, req.URL, req.Form)
-		w.Header().Set("Content-Type", "application/json")
-		switch req.URL.Path {
-		case "/v1/people/me":
-			if f := req.Form.Get("personFields"); f != "emailAddresses,names" {
-				w.WriteHeader(http.StatusBadRequest)
-				break
-			}
-			json.NewEncoder(w).Encode(s.fakePeopleAPIResponse)
-		default:
-			w.WriteHeader(http.StatusNotFound)
-		}
-	}))
-	s.fakePeopleAPIResponse = map[string]interface{}{}
+	s.fakeProvider = arvadostest.NewOIDCProvider(c)
+	s.fakeProvider.AuthEmail = "active-user at arvados.local"
+	s.fakeProvider.AuthEmailVerified = true
+	s.fakeProvider.AuthName = "Fake User Name"
+	s.fakeProvider.ValidCode = fmt.Sprintf("abcdefgh-%d", time.Now().Unix())
+	s.fakeProvider.PeopleAPIResponse = map[string]interface{}{}
 
 	cfg, err := config.NewLoader(nil, ctxlog.TestLogger(c)).Load()
 	c.Assert(err, check.IsNil)
@@ -171,13 +64,13 @@ func (s *OIDCLoginSuite) SetUpTest(c *check.C) {
 	s.cluster.Login.Google.ClientID = "test%client$id"
 	s.cluster.Login.Google.ClientSecret = "test#client/secret"
 	s.cluster.Users.PreferDomainForUsername = "PreferDomainForUsername.example.com"
-	s.validClientID = "test%client$id"
-	s.validClientSecret = "test#client/secret"
+	s.fakeProvider.ValidClientID = "test%client$id"
+	s.fakeProvider.ValidClientSecret = "test#client/secret"
 
 	s.localdb = NewConn(s.cluster)
 	c.Assert(s.localdb.loginController, check.FitsTypeOf, (*oidcLoginController)(nil))
-	s.localdb.loginController.(*oidcLoginController).Issuer = s.fakeIssuer.URL
-	s.localdb.loginController.(*oidcLoginController).peopleAPIBasePath = s.fakePeopleAPI.URL
+	s.localdb.loginController.(*oidcLoginController).Issuer = s.fakeProvider.Issuer.URL
+	s.localdb.loginController.(*oidcLoginController).peopleAPIBasePath = s.fakeProvider.PeopleAPI.URL
 
 	s.railsSpy = arvadostest.NewProxy(c, s.cluster.Services.RailsAPI)
 	*s.localdb.railsProxy = *rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider)
@@ -206,7 +99,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_Start(c *check.C) {
 		c.Check(err, check.IsNil)
 		target, err := url.Parse(resp.RedirectLocation)
 		c.Check(err, check.IsNil)
-		issuerURL, _ := url.Parse(s.fakeIssuer.URL)
+		issuerURL, _ := url.Parse(s.fakeProvider.Issuer.URL)
 		c.Check(target.Host, check.Equals, issuerURL.Host)
 		q := target.Query()
 		c.Check(q.Get("client_id"), check.Equals, "test%client$id")
@@ -232,7 +125,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_InvalidCode(c *check.C) {
 func (s *OIDCLoginSuite) TestGoogleLogin_InvalidState(c *check.C) {
 	s.startLogin(c)
 	resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
-		Code:  s.validCode,
+		Code:  s.fakeProvider.ValidCode,
 		State: "bogus-state",
 	})
 	c.Check(err, check.IsNil)
@@ -241,20 +134,20 @@ func (s *OIDCLoginSuite) TestGoogleLogin_InvalidState(c *check.C) {
 }
 
 func (s *OIDCLoginSuite) setupPeopleAPIError(c *check.C) {
-	s.fakePeopleAPI = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+	s.fakeProvider.PeopleAPI = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 		w.WriteHeader(http.StatusForbidden)
 		fmt.Fprintln(w, `Error 403: accessNotConfigured`)
 	}))
-	s.localdb.loginController.(*oidcLoginController).peopleAPIBasePath = s.fakePeopleAPI.URL
+	s.localdb.loginController.(*oidcLoginController).peopleAPIBasePath = s.fakeProvider.PeopleAPI.URL
 }
 
 func (s *OIDCLoginSuite) TestGoogleLogin_PeopleAPIDisabled(c *check.C) {
 	s.localdb.loginController.(*oidcLoginController).UseGooglePeopleAPI = false
-	s.authEmail = "joe.smith at primary.example.com"
+	s.fakeProvider.AuthEmail = "joe.smith at primary.example.com"
 	s.setupPeopleAPIError(c)
 	state := s.startLogin(c)
 	_, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
-		Code:  s.validCode,
+		Code:  s.fakeProvider.ValidCode,
 		State: state,
 	})
 	c.Check(err, check.IsNil)
@@ -294,7 +187,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_PeopleAPIError(c *check.C) {
 	s.setupPeopleAPIError(c)
 	state := s.startLogin(c)
 	resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
-		Code:  s.validCode,
+		Code:  s.fakeProvider.ValidCode,
 		State: state,
 	})
 	c.Check(err, check.IsNil)
@@ -304,11 +197,11 @@ func (s *OIDCLoginSuite) TestGoogleLogin_PeopleAPIError(c *check.C) {
 func (s *OIDCLoginSuite) TestGenericOIDCLogin(c *check.C) {
 	s.cluster.Login.Google.Enable = false
 	s.cluster.Login.OpenIDConnect.Enable = true
-	json.Unmarshal([]byte(fmt.Sprintf("%q", s.fakeIssuer.URL)), &s.cluster.Login.OpenIDConnect.Issuer)
+	json.Unmarshal([]byte(fmt.Sprintf("%q", s.fakeProvider.Issuer.URL)), &s.cluster.Login.OpenIDConnect.Issuer)
 	s.cluster.Login.OpenIDConnect.ClientID = "oidc#client#id"
 	s.cluster.Login.OpenIDConnect.ClientSecret = "oidc#client#secret"
-	s.validClientID = "oidc#client#id"
-	s.validClientSecret = "oidc#client#secret"
+	s.fakeProvider.ValidClientID = "oidc#client#id"
+	s.fakeProvider.ValidClientSecret = "oidc#client#secret"
 	for _, trial := range []struct {
 		expectEmail string // "" if failure expected
 		setup       func()
@@ -317,8 +210,8 @@ func (s *OIDCLoginSuite) TestGenericOIDCLogin(c *check.C) {
 			expectEmail: "user at oidc.example.com",
 			setup: func() {
 				c.Log("=== succeed because email_verified is false but not required")
-				s.authEmail = "user at oidc.example.com"
-				s.authEmailVerified = false
+				s.fakeProvider.AuthEmail = "user at oidc.example.com"
+				s.fakeProvider.AuthEmailVerified = false
 				s.cluster.Login.OpenIDConnect.EmailClaim = "email"
 				s.cluster.Login.OpenIDConnect.EmailVerifiedClaim = ""
 				s.cluster.Login.OpenIDConnect.UsernameClaim = ""
@@ -328,8 +221,8 @@ func (s *OIDCLoginSuite) TestGenericOIDCLogin(c *check.C) {
 			expectEmail: "",
 			setup: func() {
 				c.Log("=== fail because email_verified is false and required")
-				s.authEmail = "user at oidc.example.com"
-				s.authEmailVerified = false
+				s.fakeProvider.AuthEmail = "user at oidc.example.com"
+				s.fakeProvider.AuthEmailVerified = false
 				s.cluster.Login.OpenIDConnect.EmailClaim = "email"
 				s.cluster.Login.OpenIDConnect.EmailVerifiedClaim = "email_verified"
 				s.cluster.Login.OpenIDConnect.UsernameClaim = ""
@@ -339,8 +232,8 @@ func (s *OIDCLoginSuite) TestGenericOIDCLogin(c *check.C) {
 			expectEmail: "user at oidc.example.com",
 			setup: func() {
 				c.Log("=== succeed because email_verified is false but config uses custom 'verified' claim")
-				s.authEmail = "user at oidc.example.com"
-				s.authEmailVerified = false
+				s.fakeProvider.AuthEmail = "user at oidc.example.com"
+				s.fakeProvider.AuthEmailVerified = false
 				s.cluster.Login.OpenIDConnect.EmailClaim = "email"
 				s.cluster.Login.OpenIDConnect.EmailVerifiedClaim = "alt_verified"
 				s.cluster.Login.OpenIDConnect.UsernameClaim = ""
@@ -350,8 +243,8 @@ func (s *OIDCLoginSuite) TestGenericOIDCLogin(c *check.C) {
 			expectEmail: "alt_email at example.com",
 			setup: func() {
 				c.Log("=== succeed with custom 'email' and 'email_verified' claims")
-				s.authEmail = "bad at wrong.example.com"
-				s.authEmailVerified = false
+				s.fakeProvider.AuthEmail = "bad at wrong.example.com"
+				s.fakeProvider.AuthEmailVerified = false
 				s.cluster.Login.OpenIDConnect.EmailClaim = "alt_email"
 				s.cluster.Login.OpenIDConnect.EmailVerifiedClaim = "alt_verified"
 				s.cluster.Login.OpenIDConnect.UsernameClaim = "alt_username"
@@ -368,7 +261,7 @@ func (s *OIDCLoginSuite) TestGenericOIDCLogin(c *check.C) {
 
 		state := s.startLogin(c)
 		resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
-			Code:  s.validCode,
+			Code:  s.fakeProvider.ValidCode,
 			State: state,
 		})
 		c.Assert(err, check.IsNil)
@@ -399,7 +292,7 @@ func (s *OIDCLoginSuite) TestGenericOIDCLogin(c *check.C) {
 func (s *OIDCLoginSuite) TestGoogleLogin_Success(c *check.C) {
 	state := s.startLogin(c)
 	resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
-		Code:  s.validCode,
+		Code:  s.fakeProvider.ValidCode,
 		State: state,
 	})
 	c.Check(err, check.IsNil)
@@ -436,8 +329,8 @@ func (s *OIDCLoginSuite) TestGoogleLogin_Success(c *check.C) {
 }
 
 func (s *OIDCLoginSuite) TestGoogleLogin_RealName(c *check.C) {
-	s.authEmail = "joe.smith at primary.example.com"
-	s.fakePeopleAPIResponse = map[string]interface{}{
+	s.fakeProvider.AuthEmail = "joe.smith at primary.example.com"
+	s.fakeProvider.PeopleAPIResponse = map[string]interface{}{
 		"names": []map[string]interface{}{
 			{
 				"metadata":   map[string]interface{}{"primary": false},
@@ -453,7 +346,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_RealName(c *check.C) {
 	}
 	state := s.startLogin(c)
 	s.localdb.Login(context.Background(), arvados.LoginOptions{
-		Code:  s.validCode,
+		Code:  s.fakeProvider.ValidCode,
 		State: state,
 	})
 
@@ -463,11 +356,11 @@ func (s *OIDCLoginSuite) TestGoogleLogin_RealName(c *check.C) {
 }
 
 func (s *OIDCLoginSuite) TestGoogleLogin_OIDCRealName(c *check.C) {
-	s.authName = "Joe P. Smith"
-	s.authEmail = "joe.smith at primary.example.com"
+	s.fakeProvider.AuthName = "Joe P. Smith"
+	s.fakeProvider.AuthEmail = "joe.smith at primary.example.com"
 	state := s.startLogin(c)
 	s.localdb.Login(context.Background(), arvados.LoginOptions{
-		Code:  s.validCode,
+		Code:  s.fakeProvider.ValidCode,
 		State: state,
 	})
 
@@ -478,8 +371,8 @@ func (s *OIDCLoginSuite) TestGoogleLogin_OIDCRealName(c *check.C) {
 
 // People API returns some additional email addresses.
 func (s *OIDCLoginSuite) TestGoogleLogin_AlternateEmailAddresses(c *check.C) {
-	s.authEmail = "joe.smith at primary.example.com"
-	s.fakePeopleAPIResponse = map[string]interface{}{
+	s.fakeProvider.AuthEmail = "joe.smith at primary.example.com"
+	s.fakeProvider.PeopleAPIResponse = map[string]interface{}{
 		"emailAddresses": []map[string]interface{}{
 			{
 				"metadata": map[string]interface{}{"verified": true},
@@ -496,7 +389,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_AlternateEmailAddresses(c *check.C) {
 	}
 	state := s.startLogin(c)
 	s.localdb.Login(context.Background(), arvados.LoginOptions{
-		Code:  s.validCode,
+		Code:  s.fakeProvider.ValidCode,
 		State: state,
 	})
 
@@ -507,8 +400,8 @@ func (s *OIDCLoginSuite) TestGoogleLogin_AlternateEmailAddresses(c *check.C) {
 
 // Primary address is not the one initially returned by oidc.
 func (s *OIDCLoginSuite) TestGoogleLogin_AlternateEmailAddresses_Primary(c *check.C) {
-	s.authEmail = "joe.smith at alternate.example.com"
-	s.fakePeopleAPIResponse = map[string]interface{}{
+	s.fakeProvider.AuthEmail = "joe.smith at alternate.example.com"
+	s.fakeProvider.PeopleAPIResponse = map[string]interface{}{
 		"emailAddresses": []map[string]interface{}{
 			{
 				"metadata": map[string]interface{}{"verified": true, "primary": true},
@@ -526,7 +419,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_AlternateEmailAddresses_Primary(c *chec
 	}
 	state := s.startLogin(c)
 	s.localdb.Login(context.Background(), arvados.LoginOptions{
-		Code:  s.validCode,
+		Code:  s.fakeProvider.ValidCode,
 		State: state,
 	})
 	authinfo := getCallbackAuthInfo(c, s.railsSpy)
@@ -536,9 +429,9 @@ func (s *OIDCLoginSuite) TestGoogleLogin_AlternateEmailAddresses_Primary(c *chec
 }
 
 func (s *OIDCLoginSuite) TestGoogleLogin_NoPrimaryEmailAddress(c *check.C) {
-	s.authEmail = "joe.smith at unverified.example.com"
-	s.authEmailVerified = false
-	s.fakePeopleAPIResponse = map[string]interface{}{
+	s.fakeProvider.AuthEmail = "joe.smith at unverified.example.com"
+	s.fakeProvider.AuthEmailVerified = false
+	s.fakeProvider.PeopleAPIResponse = map[string]interface{}{
 		"emailAddresses": []map[string]interface{}{
 			{
 				"metadata": map[string]interface{}{"verified": true},
@@ -552,7 +445,7 @@ func (s *OIDCLoginSuite) TestGoogleLogin_NoPrimaryEmailAddress(c *check.C) {
 	}
 	state := s.startLogin(c)
 	s.localdb.Login(context.Background(), arvados.LoginOptions{
-		Code:  s.validCode,
+		Code:  s.fakeProvider.ValidCode,
 		State: state,
 	})
 
@@ -574,23 +467,6 @@ func (s *OIDCLoginSuite) startLogin(c *check.C) (state string) {
 	return
 }
 
-func (s *OIDCLoginSuite) fakeToken(c *check.C, payload []byte) string {
-	signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: s.issuerKey}, nil)
-	if err != nil {
-		c.Error(err)
-	}
-	object, err := signer.Sign(payload)
-	if err != nil {
-		c.Error(err)
-	}
-	t, err := object.CompactSerialize()
-	if err != nil {
-		c.Error(err)
-	}
-	c.Logf("fakeToken(%q) == %q", payload, t)
-	return t
-}
-
 func getCallbackAuthInfo(c *check.C, railsSpy *arvadostest.Proxy) (authinfo rpc.UserSessionAuthInfo) {
 	for _, dump := range railsSpy.RequestDumps {
 		c.Logf("spied request: %q", dump)
diff --git a/sdk/go/arvadostest/oidc_provider.go b/sdk/go/arvadostest/oidc_provider.go
new file mode 100644
index 000000000..0632010ba
--- /dev/null
+++ b/sdk/go/arvadostest/oidc_provider.go
@@ -0,0 +1,157 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package arvadostest
+
+import (
+	"crypto/rand"
+	"crypto/rsa"
+	"encoding/base64"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"strings"
+	"time"
+
+	"gopkg.in/check.v1"
+	"gopkg.in/square/go-jose.v2"
+)
+
+type OIDCProvider struct {
+	// expected token request
+	ValidCode         string
+	ValidClientID     string
+	ValidClientSecret string
+	// desired response from token endpoint
+	AuthEmail         string
+	AuthEmailVerified bool
+	AuthName          string
+
+	PeopleAPIResponse map[string]interface{}
+
+	key       *rsa.PrivateKey
+	Issuer    *httptest.Server
+	PeopleAPI *httptest.Server
+	c         *check.C
+}
+
+func NewOIDCProvider(c *check.C) *OIDCProvider {
+	p := &OIDCProvider{c: c}
+	var err error
+	p.key, err = rsa.GenerateKey(rand.Reader, 2048)
+	c.Assert(err, check.IsNil)
+	p.Issuer = httptest.NewServer(http.HandlerFunc(p.serveOIDC))
+	p.PeopleAPI = httptest.NewServer(http.HandlerFunc(p.servePeopleAPI))
+	return p
+}
+
+func (p *OIDCProvider) serveOIDC(w http.ResponseWriter, req *http.Request) {
+	req.ParseForm()
+	p.c.Logf("serveOIDC: got req: %s %s %s", req.Method, req.URL, req.Form)
+	w.Header().Set("Content-Type", "application/json")
+	switch req.URL.Path {
+	case "/.well-known/openid-configuration":
+		json.NewEncoder(w).Encode(map[string]interface{}{
+			"issuer":                 p.Issuer.URL,
+			"authorization_endpoint": p.Issuer.URL + "/auth",
+			"token_endpoint":         p.Issuer.URL + "/token",
+			"jwks_uri":               p.Issuer.URL + "/jwks",
+			"userinfo_endpoint":      p.Issuer.URL + "/userinfo",
+		})
+	case "/token":
+		var clientID, clientSecret string
+		auth, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(req.Header.Get("Authorization"), "Basic "))
+		authsplit := strings.Split(string(auth), ":")
+		if len(authsplit) == 2 {
+			clientID, _ = url.QueryUnescape(authsplit[0])
+			clientSecret, _ = url.QueryUnescape(authsplit[1])
+		}
+		if clientID != p.ValidClientID || clientSecret != p.ValidClientSecret {
+			p.c.Logf("OIDCProvider: expected (%q, %q) got (%q, %q)", p.ValidClientID, p.ValidClientSecret, clientID, clientSecret)
+			w.WriteHeader(http.StatusUnauthorized)
+			return
+		}
+
+		if req.Form.Get("code") != p.ValidCode || p.ValidCode == "" {
+			w.WriteHeader(http.StatusUnauthorized)
+			return
+		}
+		idToken, _ := json.Marshal(map[string]interface{}{
+			"iss":            p.Issuer.URL,
+			"aud":            []string{clientID},
+			"sub":            "fake-user-id",
+			"exp":            time.Now().UTC().Add(time.Minute).Unix(),
+			"iat":            time.Now().UTC().Unix(),
+			"nonce":          "fake-nonce",
+			"email":          p.AuthEmail,
+			"email_verified": p.AuthEmailVerified,
+			"name":           p.AuthName,
+			"alt_verified":   true,                    // for custom claim tests
+			"alt_email":      "alt_email at example.com", // for custom claim tests
+			"alt_username":   "desired-username",      // for custom claim tests
+		})
+		json.NewEncoder(w).Encode(struct {
+			AccessToken  string `json:"access_token"`
+			TokenType    string `json:"token_type"`
+			RefreshToken string `json:"refresh_token"`
+			ExpiresIn    int32  `json:"expires_in"`
+			IDToken      string `json:"id_token"`
+		}{
+			AccessToken:  p.fakeToken([]byte("fake access token")),
+			TokenType:    "Bearer",
+			RefreshToken: "test-refresh-token",
+			ExpiresIn:    30,
+			IDToken:      p.fakeToken(idToken),
+		})
+	case "/jwks":
+		json.NewEncoder(w).Encode(jose.JSONWebKeySet{
+			Keys: []jose.JSONWebKey{
+				{Key: p.key.Public(), Algorithm: string(jose.RS256), KeyID: ""},
+			},
+		})
+	case "/auth":
+		w.WriteHeader(http.StatusInternalServerError)
+	case "/userinfo":
+		w.WriteHeader(http.StatusInternalServerError)
+	default:
+		w.WriteHeader(http.StatusNotFound)
+	}
+}
+
+func (p *OIDCProvider) servePeopleAPI(w http.ResponseWriter, req *http.Request) {
+	req.ParseForm()
+	p.c.Logf("servePeopleAPI: got req: %s %s %s", req.Method, req.URL, req.Form)
+	w.Header().Set("Content-Type", "application/json")
+	switch req.URL.Path {
+	case "/v1/people/me":
+		if f := req.Form.Get("personFields"); f != "emailAddresses,names" {
+			w.WriteHeader(http.StatusBadRequest)
+			break
+		}
+		json.NewEncoder(w).Encode(p.PeopleAPIResponse)
+	default:
+		w.WriteHeader(http.StatusNotFound)
+	}
+}
+
+func (p *OIDCProvider) fakeToken(payload []byte) string {
+	signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: p.key}, nil)
+	if err != nil {
+		p.c.Error(err)
+		return ""
+	}
+	object, err := signer.Sign(payload)
+	if err != nil {
+		p.c.Error(err)
+		return ""
+	}
+	t, err := object.CompactSerialize()
+	if err != nil {
+		p.c.Error(err)
+		return ""
+	}
+	p.c.Logf("fakeToken(%q) == %q", payload, t)
+	return t
+}

-----------------------------------------------------------------------


hooks/post-receive
-- 




More information about the arvados-commits mailing list