scouting: Re-authorize users when their auth expires

A few students noted at the San Francisco regional that they get 401
errors when trying to submit scouting data. I'm not entirely convinced
how to fix that, but this patch attempts to implement a work around.

Whenever a user tries to submit data, we check for the resulting
status code. If the code is 401, then the app now opens a new tab in
an attempt to re-authorize. The tab will close itself. When the tab
closes, the app will attempt to submit the scouting data again.

Change-Id: I3a4273c9c0f571f68332877f744c9c7013a7e66a
Signed-off-by: Philipp Schrader <philipp.schrader@gmail.com>
diff --git a/.bazelignore b/.bazelignore
index 9166728..9da1960 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -8,6 +8,7 @@
 scouting/www/notes/node_modules
 scouting/www/pipes/node_modules
 scouting/www/rpc/node_modules
+scouting/www/test/authorize/node_modules
 scouting/www/shift_schedule/node_modules
 scouting/www/view/node_modules
 scouting/www/pit_scouting/node_modules
diff --git a/WORKSPACE b/WORKSPACE
index f68d1b0..253cba7 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -922,6 +922,7 @@
         "@//scouting/www/rpc:package.json",
         "@//scouting/www/scan:package.json",
         "@//scouting/www/shift_schedule:package.json",
+        "@//scouting/www/test/authorize:package.json",
         "@//scouting/www/view:package.json",
     ],
 
@@ -1037,6 +1038,20 @@
 )
 
 http_archive(
+    name = "chromedriver_linux",
+    build_file_content = """
+filegroup(
+    name = "chromedriver",
+    srcs = ["chromedriver-linux64/chromedriver"],
+    visibility = ["//visibility:public"],
+)""",
+    sha256 = "527b81f8aaf94344af4103c1166ce5e65037e7ad071c773fe354c215d547ef73",
+    urls = [
+        "https://storage.googleapis.com/chrome-for-testing-public/124.0.6367.8/linux64/chromedriver-linux64.zip",
+    ],
+)
+
+http_archive(
     name = "rules_rust_tinyjson",
     build_file = "@rules_rust//util/process_wrapper:BUILD.tinyjson.bazel",
     sha256 = "1a8304da9f9370f6a6f9020b7903b044aa9ce3470f300a1fba5bc77c78145a16",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f718ebc..5411319 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -224,6 +224,9 @@
       '@org_frc971/scouting/webserver/requests/messages':
         specifier: workspace:*
         version: link:../../webserver/requests/messages
+      '@org_frc971/scouting/www/rpc':
+        specifier: workspace:*
+        version: link:../rpc
       '@types/pako':
         specifier: 2.0.3
         version: 2.0.3
@@ -240,6 +243,15 @@
         specifier: workspace:*
         version: link:../../webserver/requests/messages
 
+  scouting/www/test/authorize:
+    dependencies:
+      '@org_frc971/scouting/www/rpc':
+        specifier: workspace:*
+        version: link:../../rpc
+      selenium-webdriver:
+        specifier: ^4.5.0
+        version: 4.18.1
+
   scouting/www/view:
     dependencies:
       '@angular/forms':
@@ -1271,6 +1283,7 @@
   /anymatch@3.1.3:
     resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
     engines: {node: '>= 8'}
+    requiresBuild: true
     dependencies:
       normalize-path: 3.0.0
       picomatch: 2.3.1
@@ -1348,6 +1361,7 @@
   /binary-extensions@2.2.0:
     resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
     engines: {node: '>=8'}
+    requiresBuild: true
 
   /bl@4.1.0:
     resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
@@ -1717,7 +1731,6 @@
 
   /core-util-is@1.0.2:
     resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
-    dev: true
 
   /cors@2.8.5:
     resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
@@ -2085,6 +2098,7 @@
   /fill-range@7.0.1:
     resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
     engines: {node: '>=8'}
+    requiresBuild: true
     dependencies:
       to-regex-range: 5.0.1
 
@@ -2247,6 +2261,7 @@
   /glob-parent@5.1.2:
     resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
     engines: {node: '>= 6'}
+    requiresBuild: true
     dependencies:
       is-glob: 4.0.3
 
@@ -2438,6 +2453,10 @@
       minimatch: 9.0.3
     dev: true
 
+  /immediate@3.0.6:
+    resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+    dev: false
+
   /imurmurhash@0.1.4:
     resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
     engines: {node: '>=0.8.19'}
@@ -2499,6 +2518,7 @@
   /is-binary-path@2.1.0:
     resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
     engines: {node: '>=8'}
+    requiresBuild: true
     dependencies:
       binary-extensions: 2.2.0
 
@@ -2537,6 +2557,7 @@
   /is-extglob@2.1.1:
     resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
     engines: {node: '>=0.10.0'}
+    requiresBuild: true
 
   /is-fullwidth-code-point@3.0.0:
     resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
@@ -2545,6 +2566,7 @@
   /is-glob@4.0.3:
     resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
     engines: {node: '>=0.10.0'}
+    requiresBuild: true
     dependencies:
       is-extglob: 2.1.1
 
@@ -2572,6 +2594,7 @@
   /is-number@7.0.0:
     resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
     engines: {node: '>=0.12.0'}
+    requiresBuild: true
 
   /is-path-inside@3.0.3:
     resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
@@ -2599,6 +2622,10 @@
       is-docker: 2.2.1
     dev: true
 
+  /isarray@1.0.0:
+    resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
+    dev: false
+
   /isbinaryfile@4.0.10:
     resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
     engines: {node: '>= 8.0.0'}
@@ -2689,6 +2716,15 @@
       verror: 1.10.0
     dev: true
 
+  /jszip@3.10.1:
+    resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
+    dependencies:
+      lie: 3.3.0
+      pako: 1.0.11
+      readable-stream: 2.3.8
+      setimmediate: 1.0.5
+    dev: false
+
   /karma-safari-launcher@1.0.0(karma@6.4.3):
     resolution: {integrity: sha512-qmypLWd6F2qrDJfAETvXDfxHvKDk+nyIjpH9xIeI3/hENr0U3nuqkxaftq73PfXZ4aOuOChA6SnLW4m4AxfRjQ==}
     peerDependencies:
@@ -2736,6 +2772,12 @@
     engines: {node: '> 0.8'}
     dev: true
 
+  /lie@3.3.0:
+    resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+    dependencies:
+      immediate: 3.0.6
+    dev: false
+
   /listr2@3.14.0(enquirer@2.3.6):
     resolution: {integrity: sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==}
     engines: {node: '>=10.0.0'}
@@ -3093,6 +3135,7 @@
   /normalize-path@3.0.0:
     resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
     engines: {node: '>=0.10.0'}
+    requiresBuild: true
 
   /npm-bundled@3.0.0:
     resolution: {integrity: sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==}
@@ -3287,6 +3330,10 @@
       - supports-color
     dev: true
 
+  /pako@1.0.11:
+    resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+    dev: false
+
   /pako@2.1.0:
     resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
 
@@ -3369,6 +3416,10 @@
     engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
     dev: true
 
+  /process-nextick-args@2.0.1:
+    resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
+    dev: false
+
   /process@0.11.10:
     resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
     engines: {node: '>= 0.6.0'}
@@ -3473,6 +3524,18 @@
       npm-normalize-package-bin: 3.0.1
     dev: true
 
+  /readable-stream@2.3.8:
+    resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
+    dependencies:
+      core-util-is: 1.0.2
+      inherits: 2.0.4
+      isarray: 1.0.0
+      process-nextick-args: 2.0.1
+      safe-buffer: 5.1.2
+      string_decoder: 1.1.1
+      util-deprecate: 1.0.2
+    dev: false
+
   /readable-stream@3.6.2:
     resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
     engines: {node: '>= 6'}
@@ -3485,6 +3548,7 @@
   /readdirp@3.6.0:
     resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
     engines: {node: '>=8.10.0'}
+    requiresBuild: true
     dependencies:
       picomatch: 2.3.1
 
@@ -3589,6 +3653,10 @@
       tslib: 2.6.0
     dev: true
 
+  /safe-buffer@5.1.2:
+    resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
+    dev: false
+
   /safe-buffer@5.2.1:
     resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
     dev: true
@@ -3596,6 +3664,18 @@
   /safer-buffer@2.1.2:
     resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
 
+  /selenium-webdriver@4.18.1:
+    resolution: {integrity: sha512-uP4OJ5wR4+VjdTi5oi/k8oieV2fIhVdVuaOPrklKghgS59w7Zz3nGa5gcG73VcU9EBRv5IZEBRhPr7qFJAj5mQ==}
+    engines: {node: '>= 14.20.0'}
+    dependencies:
+      jszip: 3.10.1
+      tmp: 0.2.1
+      ws: 8.16.0
+    transitivePeerDependencies:
+      - bufferutil
+      - utf-8-validate
+    dev: false
+
   /semver@5.7.1:
     resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==}
     hasBin: true
@@ -3625,6 +3705,10 @@
   /set-blocking@2.0.0:
     resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
 
+  /setimmediate@1.0.5:
+    resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
+    dev: false
+
   /setprototypeof@1.2.0:
     resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
 
@@ -3855,6 +3939,12 @@
       strip-ansi: 7.1.0
     dev: true
 
+  /string_decoder@1.1.1:
+    resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
+    dependencies:
+      safe-buffer: 5.1.2
+    dev: false
+
   /string_decoder@1.3.0:
     resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
     dependencies:
@@ -3961,6 +4051,7 @@
   /to-regex-range@5.0.1:
     resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
     engines: {node: '>=8.0'}
+    requiresBuild: true
     dependencies:
       is-number: 7.0.0
 
@@ -4103,7 +4194,6 @@
 
   /util-deprecate@1.0.2:
     resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
-    dev: true
 
   /utils-merge@1.0.1:
     resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
@@ -4216,6 +4306,19 @@
       utf-8-validate:
         optional: true
 
+  /ws@8.16.0:
+    resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==}
+    engines: {node: '>=10.0.0'}
+    peerDependencies:
+      bufferutil: ^4.0.1
+      utf-8-validate: '>=5.0.2'
+    peerDependenciesMeta:
+      bufferutil:
+        optional: true
+      utf-8-validate:
+        optional: true
+    dev: false
+
   /y18n@4.0.3:
     resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
 
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index cf4c338..6eac091 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -2,3 +2,4 @@
   - 'scouting/webserver/requests/messages'
   - 'scouting/www'
   - 'scouting/www/*'
+  - 'scouting/www/test/*'
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index 685fe14..c73f93a 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -7,6 +7,10 @@
 
 npm_link_all_packages(name = "node_modules")
 
+exports_files([
+    "_static_files/authorize/index.html",
+])
+
 assemble_service_worker_files(
     name = "service_worker_files",
     outs = [
@@ -43,12 +47,14 @@
 assemble_static_files(
     name = "static_files",
     srcs = [
+        "_static_files/authorize/index.html",
         ":ngsw.json",
         ":ngsw-worker.js",
         "//third_party/y2024/field:pictures",
     ],
     app_files = ":app",
     replace_prefixes = {
+        "_static_files": "",
         "prod": "",
         "dev": "",
         "third_party/y2024": "pictures",
diff --git a/scouting/www/_static_files/authorize/index.html b/scouting/www/_static_files/authorize/index.html
new file mode 100644
index 0000000..a54c182
--- /dev/null
+++ b/scouting/www/_static_files/authorize/index.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<!-- This page exists purely for re-authorizing. When the page loads
+    successfully, it will close itself. I.e. the app will open a new tab to
+    refresh the authorization. -->
+<html lang="en">
+  <head>
+    <script>
+      window.close();
+    </script>
+  </head>
+  <body></body>
+</html>
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index cd59884..41f492c 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -33,7 +33,10 @@
   ActionT,
 } from '@org_frc971/scouting/webserver/requests/messages/submit_2024_actions_generated';
 import {Match} from '@org_frc971/scouting/webserver/requests/messages/request_all_matches_response_generated';
-import {MatchListRequestor} from '@org_frc971/scouting/www/rpc';
+import {
+  MatchListRequestor,
+  ActionsSubmitter,
+} from '@org_frc971/scouting/www/rpc';
 import {ActionHelper, ConcreteAction} from './action_helper';
 import * as pako from 'pako';
 
@@ -139,7 +142,10 @@
   qrCodeValuePieces: string[] = [];
   qrCodeValueIndex: number = 0;
 
-  constructor(private readonly matchListRequestor: MatchListRequestor) {}
+  constructor(
+    private readonly matchListRequestor: MatchListRequestor,
+    private readonly actionsSubmitter: ActionsSubmitter
+  ) {}
 
   ngOnInit() {
     this.actionHelper = new ActionHelper(
@@ -366,10 +372,7 @@
   }
 
   async submit2024Actions() {
-    const res = await fetch('/requests/submit/submit_2024_actions', {
-      method: 'POST',
-      body: this.createActionsBuffer(),
-    });
+    const res = await this.actionsSubmitter.submit(this.createActionsBuffer());
 
     if (res.ok) {
       // We successfully submitted the data. Report success.
diff --git a/scouting/www/rpc/actions_submitter.ts b/scouting/www/rpc/actions_submitter.ts
new file mode 100644
index 0000000..c6eeca4
--- /dev/null
+++ b/scouting/www/rpc/actions_submitter.ts
@@ -0,0 +1,14 @@
+import {Injectable} from '@angular/core';
+import {RequestAuthorizer} from './auth_handler';
+
+@Injectable({providedIn: 'root'})
+export class ActionsSubmitter {
+  constructor(private readonly requestAuthorizer: RequestAuthorizer) {}
+
+  async submit(actionBuffer: Uint8Array): Promise<Response> {
+    return this.requestAuthorizer.submit(
+      '/requests/submit/submit_2024_actions',
+      actionBuffer
+    );
+  }
+}
diff --git a/scouting/www/rpc/auth_handler.ts b/scouting/www/rpc/auth_handler.ts
new file mode 100644
index 0000000..02a790d
--- /dev/null
+++ b/scouting/www/rpc/auth_handler.ts
@@ -0,0 +1,54 @@
+import {Injectable} from '@angular/core';
+
+const AUTHORIZATION_URL = '/authorize';
+const AUTHORIZATION_CHECK_INTERVAL = 500; // ms
+const AUTHORIZATION_CHECK_DURATION = 20000; // ms
+const AUTHORIZATION_CHECK_ATTEMPTS =
+  AUTHORIZATION_CHECK_DURATION / AUTHORIZATION_CHECK_INTERVAL;
+
+@Injectable({providedIn: 'root'})
+export class RequestAuthorizer {
+  // Submits the buffer to the specified end point.
+  async submit(path: string, actionBuffer: Uint8Array): Promise<Response> {
+    const result = await this.singleSubmit(path, actionBuffer);
+    if (result.status === 401) {
+      await this.authorize();
+      return await this.singleSubmit(path, actionBuffer);
+    } else {
+      return result;
+    }
+  }
+
+  // Actually performs the underlying submission.
+  private async singleSubmit(
+    path: string,
+    actionBuffer: Uint8Array
+  ): Promise<Response> {
+    return await fetch(path, {
+      method: 'POST',
+      body: actionBuffer,
+    });
+  }
+
+  // Open a new tab in an attempt to re-authorize the user with the server.
+  private async authorize() {
+    const authorizationWindow = window.open(AUTHORIZATION_URL);
+    // We should deal with errors opening the tab, but this is good enough for now.
+
+    const tabIsClosed = new Promise<void>((resolve, reject) => {
+      let checkCounter = 0;
+      const tabCheckTimer = setInterval(() => {
+        if (authorizationWindow.closed) {
+          clearInterval(tabCheckTimer);
+          resolve();
+        }
+        checkCounter++;
+        if (checkCounter >= AUTHORIZATION_CHECK_ATTEMPTS) {
+          clearInterval(tabCheckTimer);
+          reject();
+        }
+      }, 500);
+    });
+    await tabIsClosed;
+  }
+}
diff --git a/scouting/www/rpc/match_list_requestor.ts b/scouting/www/rpc/match_list_requestor.ts
index 97ed0b0..6f73b30 100644
--- a/scouting/www/rpc/match_list_requestor.ts
+++ b/scouting/www/rpc/match_list_requestor.ts
@@ -7,7 +7,9 @@
   RequestAllMatchesResponse,
 } from '@org_frc971/scouting/webserver/requests/messages/request_all_matches_response_generated';
 import {db, MatchListData} from './db';
+
 const MATCH_TYPE_ORDERING = ['qm', 'ef', 'qf', 'sf', 'f'];
+
 @Injectable({providedIn: 'root'})
 export class MatchListRequestor {
   async fetchMatchList(): Promise<Match[]> {
diff --git a/scouting/www/rpc/public-api.ts b/scouting/www/rpc/public-api.ts
index fe61453..ab6eb45 100644
--- a/scouting/www/rpc/public-api.ts
+++ b/scouting/www/rpc/public-api.ts
@@ -1,2 +1,4 @@
+export * from './auth_handler';
+export * from './actions_submitter';
 export * from './match_list_requestor';
 export * from './view_data_requestor';
diff --git a/scouting/www/scan/package.json b/scouting/www/scan/package.json
index 7fa3990..a53259c 100644
--- a/scouting/www/scan/package.json
+++ b/scouting/www/scan/package.json
@@ -5,6 +5,7 @@
         "pako": "2.1.0",
         "@types/pako": "2.0.3",
         "@angular/forms": "v16-lts",
-        "@org_frc971/scouting/webserver/requests/messages": "workspace:*"
+        "@org_frc971/scouting/webserver/requests/messages": "workspace:*",
+        "@org_frc971/scouting/www/rpc": "workspace:*"
     }
 }
diff --git a/scouting/www/scan/scan.component.ts b/scouting/www/scan/scan.component.ts
index 4a1c9c8..431fda3 100644
--- a/scouting/www/scan/scan.component.ts
+++ b/scouting/www/scan/scan.component.ts
@@ -1,5 +1,6 @@
 import {Component, NgZone, OnInit, ViewChild, ElementRef} from '@angular/core';
 import {ErrorResponse} from '@org_frc971/scouting/webserver/requests/messages/error_response_generated';
+import {ActionsSubmitter} from '@org_frc971/scouting/www/rpc';
 import {Builder, ByteBuffer} from 'flatbuffers';
 import * as pako from 'pako';
 
@@ -32,7 +33,10 @@
   scanStream: MediaStream | null = null;
   scanTimer: ReturnType<typeof setTimeout> | null = null;
 
-  constructor(private ngZone: NgZone) {}
+  constructor(
+    private ngZone: NgZone,
+    private actionsSubmitter: ActionsSubmitter
+  ) {}
 
   ngOnInit() {
     // If the user switched away from this tab, then the onRuntimeInitialized
@@ -179,10 +183,7 @@
     );
     const actionBuffer = pako.inflate(deflatedData);
 
-    const res = await fetch('/requests/submit/submit_2024_actions', {
-      method: 'POST',
-      body: actionBuffer,
-    });
+    const res = await this.actionsSubmitter.submit(actionBuffer);
 
     if (res.ok) {
       // We successfully submitted the data. Report success.
diff --git a/scouting/www/test/authorize/BUILD b/scouting/www/test/authorize/BUILD
new file mode 100644
index 0000000..af8bc70
--- /dev/null
+++ b/scouting/www/test/authorize/BUILD
@@ -0,0 +1,62 @@
+load("@aspect_rules_js//js:defs.bzl", "js_test")
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//scouting/www:defs.bzl", "assemble_static_files")
+load("//tools/build_rules:js.bzl", "ng_application")
+
+npm_link_all_packages(name = "node_modules")
+
+go_library(
+    name = "authorize_lib",
+    srcs = ["server.go"],
+    importpath = "github.com/frc971/971-Robot-Code/scouting/www/test/authorize",
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    visibility = ["//visibility:private"],
+)
+
+go_binary(
+    name = "authorize",
+    data = [
+        ":static_files",
+    ],
+    embed = [":authorize_lib"],
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    visibility = ["//visibility:public"],
+)
+
+ng_application(
+    name = "app",
+    html_assets = [],
+    deps = [
+        ":node_modules",
+    ],
+)
+
+assemble_static_files(
+    name = "static_files",
+    srcs = [
+        "//scouting/www:_static_files/authorize/index.html",
+    ],
+    app_files = ":app",
+    replace_prefixes = {
+        "prod": "",
+        "dev": "",
+        "scouting/www/_static_files": "",
+    },
+)
+
+js_test(
+    name = "authorize_test",
+    chdir = package_name(),
+    copy_data_to_bin = False,
+    data = [
+        ":authorize",
+        ":node_modules/selenium-webdriver",
+        "@chrome_linux//:all",
+        "@chromedriver_linux//:chromedriver",
+    ],
+    entry_point = "authorize_test.js",
+    tags = [
+        "no-remote-cache",
+    ],
+)
diff --git a/scouting/www/test/authorize/app/app.module.ts b/scouting/www/test/authorize/app/app.module.ts
new file mode 100644
index 0000000..893ab58
--- /dev/null
+++ b/scouting/www/test/authorize/app/app.module.ts
@@ -0,0 +1,12 @@
+import {BrowserModule} from '@angular/platform-browser';
+import {NgModule} from '@angular/core';
+
+import {App} from './app';
+
+@NgModule({
+  declarations: [App],
+  imports: [BrowserModule],
+  exports: [App],
+  bootstrap: [App],
+})
+export class AppModule {}
diff --git a/scouting/www/test/authorize/app/app.ng.html b/scouting/www/test/authorize/app/app.ng.html
new file mode 100644
index 0000000..d32e181
--- /dev/null
+++ b/scouting/www/test/authorize/app/app.ng.html
@@ -0,0 +1,8 @@
+<div id="message">{{ message }}</div>
+<button (click)="performSubmissionWithoutReAuth()" id="without_re_auth">
+  Submit without re-auth
+</button>
+<br />
+<button (click)="performSubmissionWithReAuth()" id="with_re_auth">
+  Submit with re-auth
+</button>
diff --git a/scouting/www/test/authorize/app/app.ts b/scouting/www/test/authorize/app/app.ts
new file mode 100644
index 0000000..569eb42
--- /dev/null
+++ b/scouting/www/test/authorize/app/app.ts
@@ -0,0 +1,41 @@
+import {Component} from '@angular/core';
+
+import {RequestAuthorizer} from '@org_frc971/scouting/www/rpc';
+
+@Component({
+  selector: 'test-app',
+  templateUrl: './app.ng.html',
+})
+export class App {
+  message: string = 'Waiting for button click';
+
+  constructor(private readonly requestAuthorizer: RequestAuthorizer) {}
+
+  // A dummy request submission that is expected to fail initially.
+  async performSubmissionWithoutReAuth() {
+    this.performSubmission(() => {
+      return fetch('/submit', {method: 'POST', body: new Uint8Array()});
+    });
+  }
+
+  // A dummy request that is expected to authorize itself if it hits a 401 error.
+  async performSubmissionWithReAuth() {
+    this.performSubmission(() => {
+      return this.requestAuthorizer.submit('/submit', new Uint8Array());
+    });
+  }
+
+  async performSubmission(callback: () => Promise<Response>) {
+    this.message = 'Starting submission';
+    try {
+      const result = await callback();
+      if (result.status != 200) {
+        this.message = `Failed to perform submission: ${result.status}!`;
+      } else {
+        this.message = await result.text();
+      }
+    } catch (error) {
+      this.message = error;
+    }
+  }
+}
diff --git a/scouting/www/test/authorize/authorize_test.js b/scouting/www/test/authorize/authorize_test.js
new file mode 100644
index 0000000..83bc755
--- /dev/null
+++ b/scouting/www/test/authorize/authorize_test.js
@@ -0,0 +1,121 @@
+/*
+ * This test here validates that the re-authorization logic works at a very high level. Ideally we
+ * would validate this with an actual Apache server, but that's a little tedious to set up. Instead,
+ * we create a custom Go server that behaves as if the user is unauthorized. This works for our
+ * immediate purposes.
+ *
+ * This test cannot be written in the usual //scouting:scouting_test because the Cypress test
+ * framework does not support dealing with multiple tabs.
+ */
+
+const child_process = require('child_process');
+const process = require('process');
+
+const {Builder, By, Key, until, Condition} = require('selenium-webdriver');
+const chrome = require('selenium-webdriver/chrome');
+
+// These are the sandboxed binaries we want to point webdriver at.
+const CHROME_PATH = '../../../../../chrome_linux/chrome';
+const CHROMEDRIVER_PATH =
+  '../../../../../chromedriver_linux/chromedriver-linux64/chromedriver';
+
+// Checks if the specified element contains the specified text.
+const elementContainsText = (element, text) => {
+  return async (driver) => {
+    const actualText = await element.getText();
+    console.log(`Current message: ${actualText}`);
+    return actualText.includes(text);
+  };
+};
+
+// Asserts that the text includes the specified "include" text.
+const expectTextToInclude = (text, include) => {
+  if (!text.includes(include)) {
+    throw new Error(`Could not find "${include}" in "${text}"`);
+  }
+};
+
+// Run the actual test. We want to validate that the re-authorization helper hits the /authorize
+// endpoint properly.
+async function runTest(driver) {
+  // Validate that the page opened properly.
+  title = await driver.getTitle();
+  expectTextToInclude(title, 'RPC Authorize Test');
+
+  // When the page first loads, it should tell us it's waiting for a button click.
+  const messageElement = await driver.wait(
+    until.elementLocated(By.id('message')),
+    10000
+  );
+  const message = await messageElement.getText();
+  expectTextToInclude(message, 'Waiting for button click');
+
+  // Click the "without re-auth" button and make sure we get a permission denied error.
+  let withoutReAuthButton = await driver.findElement(By.id('without_re_auth'));
+  withoutReAuthButton.click();
+  await driver.wait(
+    elementContainsText(messageElement, 'Failed to perform submission: 401!'),
+    10000
+  );
+
+  // Click the "with re-auth" button and validate that we get a successful re-authorization.
+  let withReAuthButton = await driver.findElement(By.id('with_re_auth'));
+  withReAuthButton.click();
+  await driver.wait(
+    elementContainsText(messageElement, 'Successful submission 1!'),
+    10000
+  );
+
+  // Now click the "without re-auth" button again and make sure it's successful.
+  withoutReAuthButton.click();
+  await driver.wait(
+    elementContainsText(messageElement, 'Successful submission 2!'),
+    10000
+  );
+}
+
+(async () => {
+  // Start the dummy server in the background.
+  console.log('Starting server.');
+  let server = child_process.spawn(
+    './authorize_/authorize',
+    ['-directory', './static_files'],
+    {stdio: ['inherit', 'inherit', 'inherit']}
+  );
+
+  // Start up Chrome in the background.
+  let chromeOptions = new chrome.Options();
+  chromeOptions.setChromeBinaryPath(CHROME_PATH);
+  chromeOptions.addArguments('--headless');
+  chromeOptions.addArguments('--no-sandbox');
+  chromeOptions.addArguments('--disable-dev-shm-usage');
+  chromeOptions.addArguments('--remote-debugging-pipe');
+
+  let service = new chrome.ServiceBuilder(CHROMEDRIVER_PATH).build();
+  const driver = chrome.Driver.createSession(chromeOptions, service);
+
+  // Load the page and wait for it to finish loading.
+  driver.get('http://localhost:8000/');
+  await driver.wait(function () {
+    return driver
+      .executeScript('return document.readyState')
+      .then(function (readyState) {
+        return readyState === 'complete';
+      });
+  });
+
+  // Run the actual test we care about.
+  let exitCode = 0;
+  try {
+    await runTest(driver);
+  } catch (error) {
+    console.log(`Failed test: ${error}`);
+    exitCode = 1;
+  }
+
+  // Shut everything down.
+  driver.quit();
+  console.log('Killing server.');
+  await server.kill();
+  process.exit(exitCode);
+})();
diff --git a/scouting/www/test/authorize/index.html b/scouting/www/test/authorize/index.html
new file mode 100644
index 0000000..c307b65
--- /dev/null
+++ b/scouting/www/test/authorize/index.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <title>RPC Authorize Test</title>
+  </head>
+  <body>
+    <test-app></test-app>
+    <noscript>
+      Please enable JavaScript to continue using this application.
+    </noscript>
+  </body>
+</html>
diff --git a/scouting/www/test/authorize/main.ts b/scouting/www/test/authorize/main.ts
new file mode 100644
index 0000000..1f8eb00
--- /dev/null
+++ b/scouting/www/test/authorize/main.ts
@@ -0,0 +1,4 @@
+import {platformBrowser} from '@angular/platform-browser';
+import {AppModule} from './app/app.module';
+
+platformBrowser().bootstrapModule(AppModule);
diff --git a/scouting/www/test/authorize/package.json b/scouting/www/test/authorize/package.json
new file mode 100644
index 0000000..58999d3
--- /dev/null
+++ b/scouting/www/test/authorize/package.json
@@ -0,0 +1,9 @@
+{
+    "name": "@org_frc971/scouting/www/test/authorize",
+    "private": true,
+    "type": "module",
+    "dependencies": {
+        "@org_frc971/scouting/www/rpc": "workspace:*",
+        "selenium-webdriver": "^4.5.0"
+    }
+}
diff --git a/scouting/www/test/authorize/polyfills.ts b/scouting/www/test/authorize/polyfills.ts
new file mode 100644
index 0000000..aa09a9f
--- /dev/null
+++ b/scouting/www/test/authorize/polyfills.ts
@@ -0,0 +1 @@
+import 'zone.js';
diff --git a/scouting/www/test/authorize/server.go b/scouting/www/test/authorize/server.go
new file mode 100644
index 0000000..3fad800
--- /dev/null
+++ b/scouting/www/test/authorize/server.go
@@ -0,0 +1,50 @@
+package main
+
+import (
+	"flag"
+	"fmt"
+	"log"
+	"net/http"
+)
+
+var authorized = false
+var authorizedCounter = 0
+
+// Special handler for responding to /submit requests.
+func handleSubmission(w http.ResponseWriter, r *http.Request) {
+	if !authorized {
+		log.Println("Replying with 'Unauthorized'.")
+		w.WriteHeader(http.StatusUnauthorized)
+		w.Write([]byte{})
+		return
+	}
+
+	log.Println("Replying with success.")
+	authorizedCounter += 1
+	w.Write([]byte(fmt.Sprintf("Successful submission %d!", authorizedCounter)))
+}
+
+// Default handler for all other requests.
+func createDefaultHandler(directory string) http.HandlerFunc {
+	handler := http.FileServer(http.Dir(directory))
+
+	fn := func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path == "/authorize" {
+			authorized = true
+		}
+		handler.ServeHTTP(w, r)
+	}
+
+	return http.HandlerFunc(fn)
+}
+
+func main() {
+	directoryPtr := flag.String("directory", ".", "The directory to serve")
+	flag.Parse()
+
+	http.HandleFunc("/", createDefaultHandler(*directoryPtr))
+	http.HandleFunc("/submit", handleSubmission)
+
+	fmt.Println("Server listening on port 8000...")
+	http.ListenAndServe(":8000", nil)
+}