Merge "scouting: Re-authorize users when their auth expires"
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)
+}