diff --git a/packages/google_identity_services_web/CHANGELOG.md b/packages/google_identity_services_web/CHANGELOG.md index 8f476099fc4..f6d8de6fa0b 100644 --- a/packages/google_identity_services_web/CHANGELOG.md +++ b/packages/google_identity_services_web/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.3.2 + +* Adds the `nonce` parameter to `loadWebSdk`. + ## 0.3.1+5 * Updates minimum supported SDK version to Flutter 3.22/Dart 3.4. diff --git a/packages/google_identity_services_web/lib/src/js_interop/package_web_tweaks.dart b/packages/google_identity_services_web/lib/src/js_interop/package_web_tweaks.dart index ed55d5d8129..e841aaa54d0 100644 --- a/packages/google_identity_services_web/lib/src/js_interop/package_web_tweaks.dart +++ b/packages/google_identity_services_web/lib/src/js_interop/package_web_tweaks.dart @@ -34,3 +34,15 @@ extension CreateScriptUrlNoArgs on web.TrustedTypePolicy { String input, ); } + +/// This extension gives web.HTMLScriptElement a nullable getter to the +/// `nonce` property, which needs to be used to check for feature support. +extension NullableNonceGetter on web.HTMLScriptElement { + /// (Nullable) Bindings to HTMLScriptElement.nonce. + /// + /// This may be null if the browser doesn't support the Nonce API. + /// + /// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce + @JS('nonce') + external String? get nullableNonce; +} diff --git a/packages/google_identity_services_web/lib/src/js_loader.dart b/packages/google_identity_services_web/lib/src/js_loader.dart index e32f59c802b..af59526501b 100644 --- a/packages/google_identity_services_web/lib/src/js_loader.dart +++ b/packages/google_identity_services_web/lib/src/js_loader.dart @@ -16,10 +16,22 @@ const String _url = 'https://accounts.google.com/gsi/client'; // The default TrustedPolicy name that will be used to inject the script. const String _defaultTrustedPolicyName = 'gis-dart'; +// Sentinel value to tell apart when users explicitly set the nonce value to `null`. +const String _undefined = '___undefined___'; + /// Loads the GIS SDK for web, using Trusted Types API when available. +/// +/// This attempts to use Trusted Types when available, and creates a new policy +/// with the given [trustedTypePolicyName]. +/// +/// By default, the script will attempt to copy the `nonce` attribute from other +/// scripts in the page. The [nonce] parameter will be used when passed, and +/// not-null. When [nonce] parameter is explicitly `null`, no `nonce` +/// attribute is applied to the script. Future loadWebSdk({ web.HTMLElement? target, String trustedTypePolicyName = _defaultTrustedPolicyName, + String? nonce = _undefined, }) { final Completer completer = Completer(); onGoogleLibraryLoad = () => completer.complete(); @@ -42,21 +54,51 @@ Future loadWebSdk({ } } - final web.HTMLScriptElement script = - web.document.createElement('script') as web.HTMLScriptElement - ..async = true - ..defer = true; + final web.HTMLScriptElement script = web.HTMLScriptElement() + ..async = true + ..defer = true; if (trustedUrl != null) { script.trustedSrc = trustedUrl; } else { script.src = _url; } + if (_getNonce(suppliedNonce: nonce) case final String nonce?) { + script.nonce = nonce; + } + (target ?? web.document.head!).appendChild(script); return completer.future; } +/// Computes the actual nonce value to use. +/// +/// If [suppliedNonce] has been explicitly passed, returns that. +/// If `suppliedNonce` is null, it attempts to locate the `nonce` +/// attribute from other script in the page. +String? _getNonce({String? suppliedNonce, web.Window? window}) { + if (suppliedNonce != _undefined) { + return suppliedNonce; + } + + final web.Window currentWindow = window ?? web.window; + final web.NodeList elements = + currentWindow.document.querySelectorAll('script'); + + for (int i = 0; i < elements.length; i++) { + if (elements.item(i) case final web.HTMLScriptElement element) { + // Chrome may return an empty string instead of null. + final String nonce = + element.nullableNonce ?? element.getAttribute('nonce') ?? ''; + if (nonce.isNotEmpty) { + return nonce; + } + } + } + return null; +} + /// Exception thrown if the Trusted Types feature is supported, enabled, and it /// has prevented this loader from injecting the JS SDK. class TrustedTypesException implements Exception { diff --git a/packages/google_identity_services_web/pubspec.yaml b/packages/google_identity_services_web/pubspec.yaml index 21f6a0c3d58..97af6ef049f 100644 --- a/packages/google_identity_services_web/pubspec.yaml +++ b/packages/google_identity_services_web/pubspec.yaml @@ -2,7 +2,7 @@ name: google_identity_services_web description: A Dart JS-interop layer for Google Identity Services. Google's new sign-in SDK for Web that supports multiple types of credentials. repository: https://github.com/flutter/packages/tree/main/packages/google_identity_services_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+google_identiy_services_web%22 -version: 0.3.1+5 +version: 0.3.2 environment: sdk: ^3.4.0 diff --git a/packages/google_identity_services_web/test/js_loader_test.dart b/packages/google_identity_services_web/test/js_loader_test.dart index cbb684acfe6..58b73bb9a4f 100644 --- a/packages/google_identity_services_web/test/js_loader_test.dart +++ b/packages/google_identity_services_web/test/js_loader_test.dart @@ -25,8 +25,11 @@ import 'package:web/web.dart' as web; void main() { group('loadWebSdk (no TrustedTypes)', () { - final web.HTMLDivElement target = - web.document.createElement('div') as web.HTMLDivElement; + final web.HTMLDivElement target = web.HTMLDivElement(); + + tearDown(() { + target.replaceChildren([].toJS); + }); test('Injects script into desired target', () async { // This test doesn't simulate the callback that completes the future, and @@ -34,7 +37,7 @@ void main() { unawaited(loadWebSdk(target: target)); // Target now should have a child that is a script element - final web.Node? injected = target.firstChild; + final web.Node? injected = target.firstElementChild; expect(injected, isNotNull); expect(injected, isA()); @@ -54,6 +57,73 @@ void main() { await expectLater(loadFuture, completes); }); + + group('`nonce` parameter', () { + test('can be set', () async { + const String expectedNonce = 'some-random-nonce'; + unawaited(loadWebSdk(target: target, nonce: expectedNonce)); + + // Target now should have a child that is a script element + final web.HTMLScriptElement script = + target.firstElementChild! as web.HTMLScriptElement; + expect(script.nonce, expectedNonce); + }); + + test('defaults to a nonce set in other script of the page', () async { + const String expectedNonce = 'another-random-nonce'; + final web.HTMLScriptElement otherScript = web.HTMLScriptElement() + ..nonce = expectedNonce; + web.document.head?.appendChild(otherScript); + + // This test doesn't simulate the callback that completes the future, and + // the code being tested runs synchronously. + unawaited(loadWebSdk(target: target)); + + // Target now should have a child that is a script element + final web.HTMLScriptElement script = + target.firstElementChild! as web.HTMLScriptElement; + expect(script.nonce, expectedNonce); + + otherScript.remove(); + }); + + test('when explicitly set overrides the default', () async { + const String expectedNonce = 'third-random-nonce'; + final web.HTMLScriptElement otherScript = web.HTMLScriptElement() + ..nonce = 'this-is-the-wrong-nonce'; + web.document.head?.appendChild(otherScript); + + // This test doesn't simulate the callback that completes the future, and + // the code being tested runs synchronously. + unawaited(loadWebSdk(target: target, nonce: expectedNonce)); + + // Target now should have a child that is a script element + final web.HTMLScriptElement script = + target.firstElementChild! as web.HTMLScriptElement; + expect(script.nonce, expectedNonce); + + otherScript.remove(); + }); + + test('when null disables the feature', () async { + final web.HTMLScriptElement otherScript = web.HTMLScriptElement() + ..nonce = 'this-is-the-wrong-nonce'; + web.document.head?.appendChild(otherScript); + + // This test doesn't simulate the callback that completes the future, and + // the code being tested runs synchronously. + unawaited(loadWebSdk(target: target, nonce: null)); + + // Target now should have a child that is a script element + final web.HTMLScriptElement script = + target.firstElementChild! as web.HTMLScriptElement; + + expect(script.nonce, isEmpty); + expect(script.hasAttribute('nonce'), isFalse); + + otherScript.remove(); + }); + }); }); }