Support credentials mode in <script type=webbundle>

The current <script>-based API doesn't allow to specify a credential
mode, while <link>-based APIs allowed that by its `crossorigin`
attribute. This is one of the gaps between two APIs.

This CL supports credentials for <script type=webbundle>. Now <script
type=webbundle> can have a `credentials` to specify a request's
credentials mode, such as:

<script type="webbundle">
{
  source: "subresources.wbn",
  credentials: "omit",
  resources: ["a.js", "b.js", "c.png"],
}
</script>

Regarding 're-using webbundle resources', we also check an equality of
credentials mode of two script elements, in addition to their bundle
URLs so that we don't re-use webbundle resources wrongly. See [1] for
details.

The related spec and explainer are:

- The spec issue is https://github.com/WICG/webpackage/issues/692.
- PR is https://github.com/WICG/webpackage/pull/694, which was already
  merged.
- Updataed Explainer:
https://github.com/WICG/webpackage/blob/main/explainers/subresource-loading.md#requests-mode-and-credentials-mode

[1] https://docs.google.com/document/d/1q_SodTcLuwya4cXt1gIRaVrkiaBfwWyPvkY1fqRKkgM/edit?resourcekey=0-dqrFOGVCYsg8WRZ4RFgwuw#heading=h.mnfqu6560twe

Bug: 1262005
Change-Id: I897e272962219ae10adf3dcd9c0c0e689b6c1d7b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3234563
Reviewed-by: Kunihiko Sakamoto <ksakamoto@chromium.org>
Reviewed-by: Takashi Toyoshima <toyoshim@chromium.org>
Reviewed-by: Hiroshige Hayashizaki <hiroshige@chromium.org>
Commit-Queue: Hayato Ito <hayato@chromium.org>
Cr-Commit-Position: refs/heads/main@{#934410}
diff --git a/web-bundle/resources/check-cookie-and-return-bundle.py b/web-bundle/resources/check-cookie-and-return-bundle.py
new file mode 100644
index 0000000..89a22c4
--- /dev/null
+++ b/web-bundle/resources/check-cookie-and-return-bundle.py
@@ -0,0 +1,29 @@
+import os
+
+
+def main(request, response):
+    origin = request.headers.get(b"origin")
+
+    if origin is not None:
+        response.headers.set(b"Access-Control-Allow-Origin", origin)
+        response.headers.set(b"Access-Control-Allow-Methods", b"GET")
+        response.headers.set(b"Access-Control-Allow-Credentials", b"true")
+
+    headers = [
+        (b"Content-Type", b"application/webbundle"),
+        (b"X-Content-Type-Options", b"nosniff"),
+    ]
+
+    cookie = request.cookies.first(b"milk", None)
+    if (cookie is not None) and cookie.value == b"1":
+        if request.GET.get(b"bundle", None) == b"cross-origin":
+            bundle = "./wbn/simple-cross-origin.wbn"
+        else:
+            bundle = "./wbn/subresource.wbn"
+        with open(
+            os.path.join(os.path.dirname(__file__), bundle),
+            "rb",
+        ) as f:
+            return (200, headers, f.read())
+    else:
+        return (400, [], "")
diff --git a/web-bundle/resources/generate-test-wbns.sh b/web-bundle/resources/generate-test-wbns.sh
index d9b7f4e..f80a265 100755
--- a/web-bundle/resources/generate-test-wbns.sh
+++ b/web-bundle/resources/generate-test-wbns.sh
@@ -156,3 +156,8 @@
   -version b2 \
   -har uuid-in-package.har \
   -o wbn/uuid-in-package.wbn
+
+gen-bundle \
+  -version b2 \
+  -har simple-cross-origin.har \
+  -o wbn/simple-cross-origin.wbn
diff --git a/web-bundle/resources/simple-cross-origin.har b/web-bundle/resources/simple-cross-origin.har
new file mode 100644
index 0000000..8566a6c
--- /dev/null
+++ b/web-bundle/resources/simple-cross-origin.har
@@ -0,0 +1,29 @@
+{
+  "log": {
+    "entries": [
+      {
+        "request": {
+          "method": "GET",
+          "url": "https://www1.web-platform.test:8444/web-bundle/resources/wbn/simple-cross-origin.txt",
+          "headers": []
+        },
+        "response": {
+          "status": 200,
+          "headers": [
+            {
+              "name": "Content-type",
+              "value": "text/plain"
+            },
+            {
+              "name": "Access-Control-Allow-Origin",
+              "value": "*"
+            }
+          ],
+          "content": {
+            "text": "hello from simple-cross-origin.txt"
+          }
+        }
+      }
+    ]
+  }
+}
diff --git a/web-bundle/resources/test-helpers.js b/web-bundle/resources/test-helpers.js
index b512937..c1ff011 100644
--- a/web-bundle/resources/test-helpers.js
+++ b/web-bundle/resources/test-helpers.js
@@ -158,9 +158,11 @@
   }
   const script = document.createElement("script");
   script.type = "webbundle";
-  script.textContent =
-      JSON.stringify({"source": url, "resources": resources});
-  // TODO(crbug.com/1245166): Support |options.crossOrigin|.
+  const json_rule  = {"source": url, "resources": resources};
+  if (options && options.credentials) {
+    json_rule.credentials = options.credentials;
+  }
+  script.textContent = JSON.stringify(json_rule);
   // TODO(crbug.com/1245166): Support |options.scopes|.
   return script;
 }
diff --git a/web-bundle/resources/wbn/simple-cross-origin.wbn b/web-bundle/resources/wbn/simple-cross-origin.wbn
new file mode 100644
index 0000000..a00cce0
--- /dev/null
+++ b/web-bundle/resources/wbn/simple-cross-origin.wbn
Binary files differ
diff --git a/web-bundle/subresource-loading/script-credentials.https.tentative.sub.html b/web-bundle/subresource-loading/script-credentials.https.tentative.sub.html
new file mode 100644
index 0000000..a1c8285
--- /dev/null
+++ b/web-bundle/subresource-loading/script-credentials.https.tentative.sub.html
@@ -0,0 +1,151 @@
+<!DOCTYPE html>
+<title>
+  Credentials in WebBundle subresource loading
+</title>
+<link
+  rel="help"
+  href="https://github.com/WICG/webpackage/blob/main/explainers/subresource-loading.md#requests-mode-and-credentials-mode"
+/>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../resources/test-helpers.js"></script>
+<body>
+  <script>
+    // In this wpt, we test a request's credential mode, which controls
+    // whether UA sends a credential or not to fetch a bundle.
+
+    // If UA sends a credential, check-cookie-and-return-{cross-oriigin}-bundle.py
+    // returns a valid format webbundle. Then, a subresource fetch should be successful.
+    // Otherwise, a subresource fetch should be rejected.
+
+    window.TEST_WEB_BUNDLE_ELEMENT_TYPE = "script";
+    setup(() => {
+      assert_true(HTMLScriptElement.supports("webbundle"));
+    });
+
+    document.cookie = "milk=1; path=/";
+
+    // Make sure to set a cookie for a cross-origin domain from where a cross
+    // origin bundle is served.
+    const setCookiePromise = fetch(
+      "https://{{domains[www1]}}:{{ports[https][0]}}/cookies/resources/set-cookie.py?name=milk&path=/web-bundle/resources/",
+      {
+        mode: "no-cors",
+        credentials: "include",
+      }
+    );
+
+    const same_origin_bundle = "../resources/check-cookie-and-return-bundle.py";
+    const cross_origin_bundle = "https://{{domains[www1]}}:{{ports[https][0]}}/web-bundle/resources/check-cookie-and-return-bundle.py?bundle=cross-origin";
+
+    const same_origin_bundle_subresource = "../resources/wbn/root.js";
+    const cross_origin_bundle_subresource = "https://{{domains[www1]}}:{{ports[https][0]}}/web-bundle/resources/wbn/simple-cross-origin.txt";
+
+    async function assertSubresourceCanBeFetched() {
+      const response = await fetch(same_origin_bundle_subresource);
+      const text = await response.text();
+      assert_equals(text, "export * from './submodule.js';\n");
+    }
+
+    async function assertCrossOriginSubresourceCanBeFetched() {
+      const response = await fetch(cross_origin_bundle_subresource);
+      const text = await response.text();
+      assert_equals(text, "hello from simple-cross-origin.txt");
+    }
+
+    function createScriptWebBundle(credentials) {
+      const options = {};
+      if (credentials) {
+        options.credentials = credentials;
+      }
+      return createWebBundleElement(same_origin_bundle, [same_origin_bundle_subresource], options);
+    }
+
+    function createScriptWebBundleCrossOrigin(credentials) {
+      const options = {};
+      if (credentials) {
+        options.credentials = credentials;
+      }
+      return createWebBundleElement(cross_origin_bundle, [cross_origin_bundle_subresource], options);
+    }
+
+    promise_test(async (t) => {
+      const script = createScriptWebBundle();
+      document.body.append(script);
+      t.add_cleanup(() => script.remove());
+
+      await assertSubresourceCanBeFetched();
+    }, "The default should send a credential to a same origin bundle");
+
+    promise_test(async (t) => {
+      const script = createScriptWebBundle("invalid");
+      document.body.append(script);
+      t.add_cleanup(() => script.remove());
+
+      await assertSubresourceCanBeFetched();
+    }, "An invalid value should send a credential to a same origin bundle");
+
+    promise_test(async (t) => {
+      const script = createScriptWebBundle("omit");
+      document.body.append(script);
+      t.add_cleanup(() => script.remove());
+
+      return promise_rejects_js(t, TypeError, fetch(same_origin_bundle_subresource))
+    }, "'omit' should not send a credential to a same origin bundle");
+
+    promise_test(async (t) => {
+      const script = createScriptWebBundle("same-origin");
+      document.body.append(script);
+      t.add_cleanup(() => script.remove());
+
+      await assertSubresourceCanBeFetched();
+    }, "'same-origin' should send a credential to a same origin bundle");
+
+    promise_test(async (t) => {
+      const script = createScriptWebBundle("include");
+      document.body.append(script);
+      t.add_cleanup(() => script.remove());
+
+      await assertSubresourceCanBeFetched();
+    }, "'include' should send a credential to a same origin bundle");
+
+    promise_test(async (t) => {
+      await setCookiePromise;
+
+      const script = createScriptWebBundleCrossOrigin("omit");
+      document.body.append(script);
+      t.add_cleanup(() => script.remove());
+
+      return promise_rejects_js(t, TypeError, fetch(cross_origin_bundle_subresource))
+    }, "'omit' should not send a credential to a cross origin bundle");
+
+    promise_test(async (t) => {
+      await setCookiePromise;
+
+      const script = createScriptWebBundleCrossOrigin("same-origin");
+      document.body.append(script);
+      t.add_cleanup(() => script.remove());
+
+      return promise_rejects_js(t, TypeError, fetch(cross_origin_bundle_subresource))
+    }, "'same-origin' should not send a credential to a cross origin bundle");
+
+    promise_test(async (t) => {
+      await setCookiePromise;
+
+      const script = createScriptWebBundleCrossOrigin("include");
+      document.body.append(script);
+      t.add_cleanup(() => script.remove());
+
+      await assertCrossOriginSubresourceCanBeFetched();
+    }, "'include' should send a credential to a cross origin bundle");
+
+
+    promise_test(async (t) => {
+      const script = createScriptWebBundleCrossOrigin("invalid");
+      document.body.append(script);
+      t.add_cleanup(() => script.remove());
+
+      return promise_rejects_js(t, TypeError, fetch(cross_origin_bundle_subresource))
+    }, "An invalid value should not send a credential to a cross origin bundle");
+  </script>
+</body>
diff --git a/web-bundle/subresource-loading/script-reuse-web-bundle-resource.https.tentative.html b/web-bundle/subresource-loading/script-reuse-web-bundle-resource.https.tentative.html
index e07ee7b..7429ae0 100644
--- a/web-bundle/subresource-loading/script-reuse-web-bundle-resource.https.tentative.html
+++ b/web-bundle/subresource-loading/script-reuse-web-bundle-resource.https.tentative.html
@@ -52,8 +52,8 @@
       return createWebBundleElement(wbn_url, /*resources=*/ [resource1]);
     }
 
-    function createScriptWebBundle2() {
-      return createWebBundleElement(wbn_url, /*resources=*/ [resource2]);
+    function createScriptWebBundle2(options) {
+      return createWebBundleElement(wbn_url, /*resources=*/ [resource2], /*options=*/ options);
     }
 
     async function appendScriptWebBundle1AndFetchResource1() {
@@ -110,6 +110,51 @@
       await appendScriptWebBundle1AndFetchResource1();
       clearWebBundleFetchCount();
 
+      // Remove script1, then append script2 with an explicit 'same-origin' credentials mode.
+      script1.remove();
+      script2 = createScriptWebBundle2({ credentials: "same-origin" });
+      document.body.append(script2);
+
+      await assertResource1CanNotBeFetched();
+      await assertResource2CanBeFetched();
+      assert_equals(webBundleFetchCount(), 0);
+    }, "Should reuse webbundle resources if a credential mode is same");
+
+    promise_test(async (t) => {
+      t.add_cleanup(cleanUp);
+      await appendScriptWebBundle1AndFetchResource1();
+      clearWebBundleFetchCount();
+
+      // Remove script1, then append script2 with a different credentials mode.
+      script1.remove();
+      script2 = createScriptWebBundle2({ credentials: "omit" });
+      document.body.append(script2);
+
+      await assertResource1CanNotBeFetched();
+      await assertResource2CanBeFetched();
+      assert_equals(webBundleFetchCount(), 1);
+    }, "Should not reuse webbundle resources if a credentials mode is different (same-origin vs omit)");
+
+    promise_test(async (t) => {
+      t.add_cleanup(cleanUp);
+      await appendScriptWebBundle1AndFetchResource1();
+      clearWebBundleFetchCount();
+
+      // Remove script1, then append script2 with a different credentials mode.
+      script1.remove();
+      script2 = createScriptWebBundle2({ credentials: "include" });
+      document.body.append(script2);
+
+      await assertResource1CanNotBeFetched();
+      await assertResource2CanBeFetched();
+      assert_equals(webBundleFetchCount(), 1);
+    }, "Should not reuse webbundle resources if a credential mode is different (same-origin vs include");
+
+    promise_test(async (t) => {
+      t.add_cleanup(cleanUp);
+      await appendScriptWebBundle1AndFetchResource1();
+      clearWebBundleFetchCount();
+
       // Remove script1, then append the removed one.
       script1.remove();
       document.body.append(script1);