[ARVADOS] updated: 2.1.0-34-g64028c0ca

Git user git at public.arvados.org
Tue Oct 20 21:19:01 UTC 2020


Summary of changes:
 .licenseignore                                     |    4 +
 CONTRIBUTING.md                                    |    2 +-
 .../app/controllers/trash_items_controller.rb      |   12 +-
 apps/workbench/app/models/container_request.rb     |    4 +
 apps/workbench/app/models/user.rb                  |    2 +-
 build/build-dev-docker-jobs-image.sh               |    2 +
 build/package-build-dockerfiles/Makefile           |   10 +-
 build/package-test-dockerfiles/Makefile            |   10 +-
 build/rails-package-scripts/postinst.sh            |    2 +
 build/run-build-docker-jobs-image.sh               |   28 +-
 build/run-library.sh                               |   22 +-
 build/version-at-commit.sh                         |    5 +-
 doc/Gemfile                                        |    2 +-
 doc/Gemfile.lock                                   |    2 +-
 doc/_config.yml                                    |    7 +-
 doc/_includes/_compute_ping_rb.liquid              |  290 --
 doc/_includes/_example_sdk_go.liquid               |    8 +-
 doc/_includes/_tutorial_hello_cwl.liquid           |    7 +-
 .../collection-versioning.html.textile.liquid      |    2 +-
 doc/admin/federation.html.textile.liquid           |   35 +-
 doc/admin/upgrading.html.textile.liquid            |    6 +-
 doc/admin/user-management-cli.html.textile.liquid  |   46 +
 doc/api/keep-s3.html.textile.liquid                |   74 +
 doc/api/keep-web-urls.html.textile.liquid          |   75 +
 doc/api/keep-webdav.html.textile.liquid            |  103 +
 doc/api/methods/groups.html.textile.liquid         |    2 +
 doc/api/tokens.html.textile.liquid                 |    2 +-
 doc/architecture/Arvados_arch.odg                  |  Bin 14997 -> 17162 bytes
 doc/architecture/federation.html.textile.liquid    |    8 +-
 doc/images/Arvados_arch.svg                        |  450 +-
 .../install-compute-ping.html.textile.liquid       |   14 -
 ...nstall-manual-prerequisites.html.textile.liquid |    3 +
 doc/install/new_cluster_checklist_AWS.xlsx         |  Bin 5647 -> 5712 bytes
 doc/install/new_cluster_checklist_Azure.xlsx       |  Bin 5666 -> 5748 bytes
 doc/install/new_cluster_checklist_slurm.xlsx       |  Bin 5645 -> 5669 bytes
 doc/sdk/cli/reference.html.textile.liquid          |    2 +
 doc/sdk/go/example.html.textile.liquid             |    4 +-
 .../git-arvados-guide.html.textile.liquid          |    2 +-
 doc/zenweb-liquid.rb                               |   14 +-
 lib/cloud/cloudtest/tester.go                      |    3 +-
 lib/cmd/cmd.go                                     |    2 +-
 lib/config/config.default.yml                      |   25 +
 lib/config/export.go                               |    2 +
 lib/config/generated_config.go                     |   25 +
 lib/controller/federation/conn.go                  |   17 +-
 lib/controller/federation/login_test.go            |    2 -
 lib/controller/federation/user_test.go             |   54 +
 lib/controller/localdb/login.go                    |   23 +-
 lib/controller/localdb/login_testuser.go           |    2 +-
 lib/crunchrun/logging.go                           |    3 +-
 lib/dispatchcloud/container/queue.go               |    6 +-
 lib/dispatchcloud/test/queue.go                    |    9 +-
 lib/install/deps.go                                |    9 +-
 lib/pam/pam-configs-arvados                        |    9 +-
 sdk/R/DESCRIPTION                                  |    6 +-
 sdk/R/R/Arvados.R                                  | 4821 +++++++-------------
 sdk/R/R/ArvadosFile.R                              |    2 -
 sdk/R/R/Collection.R                               |    8 +-
 sdk/R/R/CollectionTree.R                           |    4 -
 sdk/R/R/HttpParser.R                               |    5 +-
 sdk/R/R/HttpRequest.R                              |    4 +-
 sdk/R/R/RESTService.R                              |   15 +-
 sdk/R/R/Subcollection.R                            |    2 -
 sdk/R/R/autoGenAPI.R                               |   12 +-
 sdk/R/README.Rmd                                   |   22 +-
 sdk/R/tests/testthat/test-ArvadosFile.R            |   12 +-
 sdk/R/tests/testthat/test-Collection.R             |   22 +-
 sdk/R/tests/testthat/test-CollectionTree.R         |   22 +-
 sdk/R/tests/testthat/test-HttpParser.R             |    8 +-
 sdk/R/tests/testthat/test-HttpRequest.R            |   14 +-
 sdk/R/tests/testthat/test-RESTService.R            |   54 +-
 sdk/R/tests/testthat/test-Subcollection.R          |   28 +-
 sdk/cli/arvados-cli.gemspec                        |    1 +
 sdk/cwl/arvados_cwl/runner.py                      |    2 +-
 sdk/cwl/arvados_version.py                         |    9 +-
 sdk/cwl/test_with_arvbox.sh                        |    4 +-
 sdk/cwl/tests/federation/arvboxcwl/fed-config.cwl  |    3 +-
 sdk/cwl/tests/federation/arvboxcwl/start.cwl       |    2 +-
 sdk/go/arvados/config.go                           |   10 +-
 sdk/go/arvados/fs_base.go                          |    3 +-
 sdk/go/arvados/fs_collection.go                    |   29 +-
 sdk/go/auth/auth.go                                |    2 +-
 sdk/go/blockdigest/blockdigest.go                  |    6 +-
 sdk/go/blockdigest/testing.go                      |    2 +-
 sdk/go/health/handler_test.go                      |    3 +-
 sdk/go/httpserver/logger.go                        |    3 +-
 sdk/go/keepclient/keepclient.go                    |   17 +-
 sdk/go/keepclient/keepclient_test.go               |   38 +-
 sdk/go/keepclient/support.go                       |   38 +-
 sdk/go/manifest/manifest.go                        |   68 +-
 sdk/python/arvados/api.py                          |    4 +
 sdk/python/arvados_version.py                      |    9 +-
 sdk/python/tests/fed-migrate/README                |    4 +-
 sdk/python/tests/fed-migrate/fed-migrate.cwl       |    6 +-
 sdk/python/tests/fed-migrate/fed-migrate.cwlex     |    6 +-
 sdk/python/tests/fed-migrate/superuser-tok.cwl     |    2 +-
 sdk/ruby/arvados.gemspec                           |    1 +
 .../api/app/controllers/application_controller.rb  |    4 +-
 .../v1/api_client_authorizations_controller.rb     |    8 +-
 .../arvados/v1/collections_controller.rb           |   32 +-
 .../arvados/v1/container_requests_controller.rb    |    4 +-
 .../controllers/arvados/v1/groups_controller.rb    |   23 +-
 .../app/controllers/arvados/v1/jobs_controller.rb  |    8 +-
 .../app/controllers/arvados/v1/users_controller.rb |   26 +-
 services/api/app/models/api_client.rb              |   21 +-
 .../api/app/models/api_client_authorization.rb     |   59 +-
 services/api/app/models/user.rb                    |   56 +-
 .../views/user_notifier/account_is_setup.text.erb  |   15 +-
 services/api/config/arvados_config.rb              |    1 +
 services/api/test/fixtures/links.yml               |   14 +
 .../arvados/v1/groups_controller_test.rb           |   33 +
 .../arvados/v1/schema_controller_test.rb           |    2 +-
 .../functional/arvados/v1/users_controller_test.rb |   17 +
 services/api/test/integration/remote_user_test.rb  |  102 +
 services/api/test/unit/api_client_test.rb          |    4 +
 services/api/test/unit/user_notifier_test.rb       |   18 +
 services/api/test/unit/user_test.rb                |    5 +-
 services/dockercleaner/arvados_version.py          |    9 +-
 services/fuse/arvados_version.py                   |    9 +-
 services/keep-web/cache.go                         |   21 +-
 services/keep-web/doc.go                           |  156 +-
 services/keep-web/s3.go                            |  174 +-
 services/keep-web/s3_test.go                       |   66 +-
 services/keep-web/s3aws_test.go                    |   78 +
 services/keep-web/server_test.go                   |    1 +
 services/keepproxy/keepproxy.go                    |   44 +-
 services/keepstore/volume.go                       |    3 +-
 services/login-sync/arvados-login-sync.gemspec     |    1 +
 services/ws/doc.go                                 |    2 +-
 tools/arvbox/bin/arvbox                            |  105 +-
 tools/arvbox/lib/arvbox/docker/Dockerfile.base     |  166 +-
 tools/arvbox/lib/arvbox/docker/Dockerfile.demo     |   20 +-
 tools/arvbox/lib/arvbox/docker/Dockerfile.dev      |    8 +-
 tools/arvbox/lib/arvbox/docker/api-setup.sh        |   38 +-
 tools/arvbox/lib/arvbox/docker/cluster-config.sh   |   99 +-
 tools/arvbox/lib/arvbox/docker/common.sh           |   28 +-
 tools/arvbox/lib/arvbox/docker/createusers.sh      |   21 +-
 tools/arvbox/lib/arvbox/docker/devenv.sh           |    3 +-
 tools/arvbox/lib/arvbox/docker/go-setup.sh         |   13 +-
 tools/arvbox/lib/arvbox/docker/keep-setup.sh       |   40 +-
 tools/arvbox/lib/arvbox/docker/runit/2             |    2 +-
 tools/arvbox/lib/arvbox/docker/runsu.sh            |    9 +-
 .../lib/arvbox/docker/service/api/run-service      |   12 +-
 .../docker/service/arv-git-httpd/run-service       |    2 +-
 .../lib/arvbox/docker/service/certificate/run      |    8 +-
 .../lib/arvbox/docker/service/controller/run       |    2 +-
 .../service/crunch-dispatch-local/run-service      |    2 +-
 .../lib/arvbox/docker/service/doc/run-service      |    7 +-
 .../lib/arvbox/docker/service/gitolite/run-service |   42 +-
 .../arvbox/docker/service/keepproxy/run-service    |   23 -
 tools/arvbox/lib/arvbox/docker/service/nginx/run   |   20 +-
 .../arvbox/lib/arvbox/docker/service/postgres/run  |    3 +-
 .../lib/arvbox/docker/service/postgres/run-service |    1 -
 .../lib/arvbox/docker/service/ready/run-service    |    8 +-
 tools/arvbox/lib/arvbox/docker/service/vm/run      |    4 +-
 .../lib/arvbox/docker/service/vm/run-service       |   10 +-
 .../lib/arvbox/docker/service/websockets/run       |    2 +-
 .../arvbox/lib/arvbox/docker/service/workbench/run |    8 +-
 .../arvbox/docker/service/workbench/run-service    |   42 +-
 .../arvbox/docker/service/workbench2/run-service   |    8 +-
 tools/arvbox/lib/arvbox/docker/waitforpostgres.sh  |    2 +-
 tools/crunchstat-summary/arvados_version.py        |    9 +-
 tools/sync-groups/sync-groups.go                   |   10 +-
 tools/sync-groups/sync-groups_test.go              |    8 -
 164 files changed, 3727 insertions(+), 4859 deletions(-)
 delete mode 100644 doc/_includes/_compute_ping_rb.liquid
 create mode 100644 doc/api/keep-s3.html.textile.liquid
 create mode 100644 doc/api/keep-web-urls.html.textile.liquid
 create mode 100644 doc/api/keep-webdav.html.textile.liquid
 delete mode 100644 doc/install/install-compute-ping.html.textile.liquid
 create mode 100644 services/keep-web/s3aws_test.go

  discards  43a3273c3aaed1d28c21a83890810eb62783cf32 (commit)
  discards  e66c84bc8eda339d0c49f9c957b2ed896d0612cb (commit)
  discards  aaf9f357f3456583b3e78f042a356d39556244e1 (commit)
  discards  0bc9e51d520d13367465d92a84555f9c97361dfe (commit)
  discards  6b9361b4452322c7dd2d9fca288d584041daa68a (commit)
  discards  b5170eb0840e776e2178bf53a8e7998c1de891e7 (commit)
  discards  f7509ef43e7ae0138ea0fdd21c44b6dc4ed00750 (commit)
  discards  aacd7b0eb873d707b51441fc5478582850ea07f4 (commit)
  discards  c94d54dfdb7ce8a3dc379e5cfc5724f7add8205d (commit)
  discards  6eb3520652183719514dd63ecf6e6d0fab11f52c (commit)
  discards  991d818835796e84f973abdfdb83185ae72c2b21 (commit)
       via  64028c0cae469d3da33878a30bd40c5409e64641 (commit)
       via  6a4270f24aeb1ce2ec12fee990c658775d85556c (commit)
       via  9258d686496bba1b506d6218e9db6218aba47dca (commit)
       via  ecaaec38460822e89d2cec0105edba8a97391f4b (commit)
       via  f2f2e01662a21e50f41904f27edb51a701d28880 (commit)
       via  12dfef9261e029523c12dfb6822318a6f4da9856 (commit)
       via  3241c5c3a0c0e23628063df7b29e999371f35f8b (commit)
       via  65dbf2abe5015e5c179ef0ce55fb37c72683fa3b (commit)
       via  c2089c0676a1ecbd5a23ed9622fc4de104dd8e7b (commit)
       via  dc095f78d294a0df313eaa29cb20136acb495705 (commit)
       via  a3d1d3b63a7dc87269e65896637284f4a57959af (commit)
       via  dad333f819ba1e4c7634527130634da40585f3aa (commit)
       via  ff6785340ccbe4436bc0ee3b81cf084b3456a15d (commit)
       via  7a1a5ddcb4af8a8f81511a1e36e1157a288a4677 (commit)
       via  359187c8fc0c0a72ba66222c61f19db0f617e3d9 (commit)
       via  e46e9a2a7560e6d349ed0ad128a9e6da4abd25f1 (commit)
       via  5db3c780fe883be7d88b33a88ca0bc57deb868f2 (commit)
       via  1eaeddc8f1e15dd23220c4511b36e802daad3950 (commit)
       via  4878068a7b74974b053c619350f8bd58be029c9b (commit)
       via  e94ddba9d544b173e5b56b41c6ac76ea0b072a26 (commit)
       via  c95df2beb29c07c9be48f05a20c628ad437c142f (commit)
       via  aa2908778c80944e7141f4819a15c95b8dd4eeb0 (commit)
       via  8127b5d5dd999248731dd67c1c99e2045795e3e2 (commit)
       via  c6c2f3518bc745eed95b5f5b81db5d17db4366ff (commit)
       via  f8e0c7ac7a834733e189a906c2f9299f9ed010f5 (commit)
       via  cf48ae2fab8908c3933f5d705f32f0188af656f6 (commit)
       via  3804a550dd9b1a3a91dd38cc6fecd35f4b268678 (commit)
       via  960ec481104a8d378b1e23be7faa8c10c5fad657 (commit)
       via  729b2762630b343b50aa1cb74733635ebcc52eb4 (commit)
       via  c0cbdeb1567d4a4f190a01d3fe89aa975e51e47b (commit)
       via  79393ed87b04db4c4c906890a4f9793f95efb27f (commit)
       via  9c68245e24eb0553c2bb56c4cfbee60bda469281 (commit)
       via  54e8f7060b89ff28b316883798ff6080fc3f166d (commit)
       via  c2565be8a4af26ffe798d9d83d4f4119046f83b4 (commit)
       via  885a103aafafc37a5ebc5052feca6453cd0f096a (commit)
       via  26d877228efa7b24e9c266748cf4c5edeafbc3b5 (commit)
       via  c20d55097c736db8881db8702ed89501020c2b5a (commit)
       via  7ce1e5122b6e913d90010254009c3c9efc5e1f60 (commit)
       via  6440951d141dbb7ec953a90f0bd5d7f47035707d (commit)
       via  9df2ccdfc085a8b33aed9568c433b7f6e2c24353 (commit)
       via  2958a94fafbab941f8d6eb76bb2785b5c2868d3d (commit)
       via  33cd4c0daf9bb5134d14fc34389e19697fa2ea3c (commit)
       via  146087a2dd14c5b564a860d77c88f6da07edaf94 (commit)
       via  ab9833a2d881e18f15bf6c9d39126afbcd0a48c9 (commit)
       via  0c98c5c9c5902a94dc614736a46b43fd43faba6e (commit)
       via  4e8c0dc4cf8077ef4805b5c3a329d856bb3a5261 (commit)
       via  52fa64f15a837ea4929183813ee34e68fea67640 (commit)
       via  18b6c49d69b8264273150cd29b2bf0b57c54e2a8 (commit)
       via  2af7b0336b2b92e38f6966b8bbc233c05704815d (commit)
       via  5d91989697c9954f346ce77b95a8a83f54ee6957 (commit)
       via  8109f3ba459c2fed2111fb72523637fa12b40ffc (commit)
       via  1f26b2f67d5f01c003c840be909bad6693cec045 (commit)
       via  19f9424903f6b4997dc5a6c299faf70d5fdf4744 (commit)
       via  24203f1ee9af616dc3d2974465bc090dbb21eee5 (commit)
       via  a62f366d7c2b236aba0eceef37098a1fbe89c03d (commit)
       via  8d86e150e2396789cffa11279542b961c6f41650 (commit)
       via  764da69855e222afd3ba888c34e6fe10f3578aca (commit)
       via  f88b3d8cad3775806f1fc7ac8a382cba7e3be639 (commit)
       via  bdabe39ff5360f904de323cd850195237179dcaf (commit)
       via  119720800f986c4f09601ff2bf65f0309fab8a99 (commit)
       via  9deed27d5b61b2e51de1c70ae8baf06f18588e4d (commit)
       via  0924b8c3a5d500a480018c045264202ea6cb630b (commit)
       via  75ddf9c801c1212d9ac0e674aff348a0f591bace (commit)
       via  33f712764465c29c20acd025ad8421726df6423f (commit)
       via  3facf89bf048487ee718fe15d012b489f2d407b7 (commit)
       via  1baadb52c16c2d173a08845004cc33278e041c15 (commit)
       via  de961f4152f551692ac8a8b4392f971496273844 (commit)
       via  da8b6f7d9582d0829e1318f7f914730a47112e7a (commit)
       via  fa785db309d7b53905d327d6bfcab6445537a75f (commit)
       via  02d601eb27bd5b5217b9ee25869118eae406207c (commit)
       via  cc8cffec8e1c612b6be03f4446ab6beebf479f5b (commit)
       via  bf3624d8dc43bb98d9cf329657b2178181bdfb35 (commit)
       via  d356f441d55dfdc26a0ec3f1db344923b1e9b79d (commit)
       via  4634996f95b20da8bf0f523e4b901e2a83a43633 (commit)
       via  79455c249d7227627948b5b9ff121efd42fbc4fe (commit)
       via  08bf214e0170019e78c4c5496944ede12bf14978 (commit)
       via  45cabb9bf786f7d90fa7a9b89de0e40a871a793b (commit)
       via  2abdbcf641c2c6cb14764a3ed0d83849a4c7736a (commit)
       via  fa78e264ef585f02348f4f5c0a7183746a708a8f (commit)
       via  cabf89d1fd8b40a2624d101a95c6587bfdd91fed (commit)
       via  b77ee5df26c7483f63f18f116e5facf86d0512be (commit)
       via  726afb550fa67028a4c122ec8dd00838ef4723de (commit)
       via  7301e68e41869fd5931ef0b0f80890aa1220938d (commit)
       via  0dc94486b18b8797d3970eb9a982a7c9de3ada88 (commit)
       via  e7279d31e76f64a608c111b075906d46abf09c14 (commit)
       via  8fb2167d3f59ed408a413d90e727a39978a5c0a5 (commit)
       via  8d3f2e69e8427c8541d1796329d63ec84955e2c5 (commit)
       via  2a1a143938bfbfa9713c8f368898a8dda1ac685f (commit)
       via  5cefb12be01e9df6997f88696048ecb5d77d305e (commit)
       via  67d6cbddddda5fb295c23f31499e6a8316345493 (commit)
       via  15239a9f1bcd4897dd63df774bb8792a8055295a (commit)
       via  bc25855ab7a6aa0e75494f303889d8ca9fcc41f4 (commit)
       via  b096358dfbd438e89d77b6d4899817b82aca3ca3 (commit)
       via  a1305701c34508c80f638e2c7666b255019ba9a4 (commit)
       via  c9c0706ab97753cc8517096b66057d418908cd35 (commit)
       via  4f65b579bf1247a04d48a53b6cc5111c34e6a5a0 (commit)
       via  7d91fe636e1ce09697fdff28b43e4020df041f17 (commit)
       via  733143de395a2dd5949a529d6c3880f8e2b21470 (commit)
       via  bcb16d1825fd2e3105a51a2a2f9a119d71f33c8d (commit)
       via  ac82dcc51a03f0ac1e3b6fc8e9e65ab86872ac26 (commit)
       via  72f669602572eeccb7d251df669bcfbe63b137d2 (commit)
       via  22e3ec0b8647ba1236d1f0e0cd55dcdb4cca7702 (commit)
       via  b23a4f4386ae1fba418b419a6f428071fcbe2707 (commit)
       via  a463a62cdef50691f333c5c6f0d2860a542e138a (commit)
       via  1fb68cf0a5f6ad058a54d4f822385983b3504987 (commit)
       via  2e8b59d84088588205d1d649f689008bae631b6f (commit)
       via  a101ebf75507a0913d2ed324bf0efcec982b27cd (commit)
       via  71cc832bcd92fccf4f867cda3b49320cbb51785e (commit)
       via  6a67a1b576bb695e9b274c277b7220590da1a39d (commit)
       via  bbc1590401a1d15a4ca101df37415c9a2aa99ac9 (commit)
       via  6d419ba53592a3e60c8aef5be7bc99643ab3a6ac (commit)
       via  b27c53dedf632f614356305bc624befa5477b98e (commit)
       via  27992e62e71eb2ad4297c4eb3f4e787c4b000b90 (commit)
       via  2bf23f1aca56b8f3c5a330d38018a971dcb50fd6 (commit)
       via  5c64431a097bd2659127c653004c58203811f9e1 (commit)
       via  7874e2fb4eae63fa9bcadb2edff088213c0e7478 (commit)
       via  d6ea055606feac9b70a0419bce5405e5274633e7 (commit)
       via  76073b912b371c3940087897aecdce4430b7aa1d (commit)
       via  69274f95798389376e00de7f07a9822858c3ee62 (commit)
       via  154bfc562eafc642cc801f25b3c258e3846633ba (commit)
       via  4adf06fe21ff6aa9c08afee2268871c85394b217 (commit)
       via  580d77ef4d6b244971bc26c649e017e912ca8737 (commit)
       via  b15d39ec33dde9639f09bd1aff22fde7806aa24a (commit)
       via  5132075320db7a19e12a5454a70f894c30e917e8 (commit)

This update added new revisions after undoing existing revisions.  That is
to say, the old revision is not a strict subset of the new revision.  This
situation occurs when you --force push a change and generate a repository
containing something like this:

 * -- * -- B -- O -- O -- O (43a3273c3aaed1d28c21a83890810eb62783cf32)
            \
             N -- N -- N (64028c0cae469d3da33878a30bd40c5409e64641)

When this happens we assume that you've already had alert emails for all
of the O revisions, and so we here report only the revisions in the N
branch from the common base, B.

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.


commit 64028c0cae469d3da33878a30bd40c5409e64641
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 6a4270f24aeb1ce2ec12fee990c658775d85556c
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 9258d686496bba1b506d6218e9db6218aba47dca
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 ecaaec38460822e89d2cec0105edba8a97391f4b
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 f2f2e01662a21e50f41904f27edb51a701d28880
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 12dfef9261e029523c12dfb6822318a6f4da9856
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 3241c5c3a0c0e23628063df7b29e999371f35f8b
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 65dbf2abe5015e5c179ef0ce55fb37c72683fa3b
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 c2089c0676a1ecbd5a23ed9622fc4de104dd8e7b
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 dc095f78d294a0df313eaa29cb20136acb495705
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 a3d1d3b63a7dc87269e65896637284f4a57959af
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