Browse Source

Add a prototype using Firebase App Check (#314)

Co-authored-by: dmaclach <dmaclach@gmail.com>
mdmathias 2 years ago
parent
commit
c78104670e
34 changed files with 1971 additions and 66 deletions
  1. 3 0
      .gitignore
  2. 2 1
      GoogleSignIn.podspec
  3. 59 0
      GoogleSignIn/Sources/GIDAppCheck/API/GIDAppCheckProvider.h
  4. 38 0
      GoogleSignIn/Sources/GIDAppCheck/Implementations/GIDAppCheck.h
  5. 135 0
      GoogleSignIn/Sources/GIDAppCheck/Implementations/GIDAppCheck.m
  6. 37 0
      GoogleSignIn/Sources/GIDAppCheck/UI/GIDActivityIndicatorViewController.h
  7. 42 0
      GoogleSignIn/Sources/GIDAppCheck/UI/GIDActivityIndicatorViewController.m
  8. 37 0
      GoogleSignIn/Sources/GIDAppCheckTokenFetcher/API/GIDAppCheckTokenFetcher.h
  9. 29 0
      GoogleSignIn/Sources/GIDAppCheckTokenFetcher/Implementations/FIRAppCheck+GIDAppCheckTokenFetcher.h
  10. 45 0
      GoogleSignIn/Sources/GIDAppCheckTokenFetcher/Implementations/GIDAppCheckTokenFetcherFake.h
  11. 51 0
      GoogleSignIn/Sources/GIDAppCheckTokenFetcher/Implementations/GIDAppCheckTokenFetcherFake.m
  12. 1 0
      GoogleSignIn/Sources/GIDGoogleUser.m
  13. 156 37
      GoogleSignIn/Sources/GIDSignIn.m
  14. 9 4
      GoogleSignIn/Sources/GIDSignIn_Private.h
  15. 35 0
      GoogleSignIn/Sources/Public/GoogleSignIn/GIDAppCheckError.h
  16. 23 6
      GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h
  17. 3 0
      GoogleSignIn/Sources/Public/GoogleSignIn/GoogleSignIn.h
  18. 185 0
      GoogleSignIn/Tests/Unit/GIDAppCheckTest.m
  19. 90 15
      GoogleSignIn/Tests/Unit/GIDSignInTest.m
  20. 2 2
      GoogleSignInSwiftSupport.podspec
  21. 7 1
      Package.swift
  22. 434 0
      Samples/Swift/AppAttestExample/AppAttestExample.xcodeproj/project.pbxproj
  23. 84 0
      Samples/Swift/AppAttestExample/AppAttestExample.xcodeproj/xcshareddata/xcschemes/AppAttestExample.xcscheme
  24. 8 0
      Samples/Swift/AppAttestExample/AppAttestExample/AppAttestExample.entitlements
  25. 54 0
      Samples/Swift/AppAttestExample/AppAttestExample/AppAttestExampleApp.swift
  26. 25 0
      Samples/Swift/AppAttestExample/AppAttestExample/BirthdayAppCheckProviderFactory.swift
  27. 111 0
      Samples/Swift/AppAttestExample/AppAttestExample/BirthdayLoader.swift
  28. 218 0
      Samples/Swift/AppAttestExample/AppAttestExample/ContentView.swift
  29. 11 0
      Samples/Swift/AppAttestExample/AppAttestExample/Preview Content/Preview Assets.xcassets/Assets/AccentColor.colorset/Contents.json
  30. 13 0
      Samples/Swift/AppAttestExample/AppAttestExample/Preview Content/Preview Assets.xcassets/Assets/AppIcon.appiconset/Contents.json
  31. 6 0
      Samples/Swift/AppAttestExample/AppAttestExample/Preview Content/Preview Assets.xcassets/Assets/Contents.json
  32. 6 0
      Samples/Swift/AppAttestExample/AppAttestExample/Preview Content/Preview Assets.xcassets/Contents.json
  33. 12 0
      Samples/Swift/AppAttestExample/Podfile
  34. 0 0
      Samples/Swift/AppAttestExample/README.md

+ 3 - 0
.gitignore

@@ -16,3 +16,6 @@ Credentials.xcconfig
 Pods/
 gen/
 Podfile.lock
+
+# Firebase App Check Example
+**/GoogleService-Info.plist

+ 2 - 1
GoogleSignIn.podspec

@@ -13,7 +13,7 @@ The Google Sign-In SDK allows users to sign in with their Google account from th
     :tag => s.version.to_s
   }
   s.swift_version = '4.0'
-  ios_deployment_target = '10.0'
+  ios_deployment_target = '11.0'
   osx_deployment_target = '10.15'
   s.ios.deployment_target = ios_deployment_target
   s.osx.deployment_target = osx_deployment_target
@@ -33,6 +33,7 @@ The Google Sign-In SDK allows users to sign in with their Google account from th
   ]
   s.ios.framework = 'UIKit'
   s.osx.framework = 'AppKit'
+  s.dependency 'FirebaseAppCheck', '~> 10.0'
   s.dependency 'AppAuth', '~> 1.6'
   s.dependency 'GTMAppAuth', '~> 4.0'
   s.dependency 'GTMSessionFetcher/Core', '>= 1.1', '< 4.0'

+ 59 - 0
GoogleSignIn/Sources/GIDAppCheck/API/GIDAppCheckProvider.h

@@ -0,0 +1,59 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#import <Foundation/Foundation.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+@protocol GIDAppCheckTokenFetcher;
+@class FIRAppCheckToken;
+
+/// Interface providing the API for both pre-warming `GIDSignIn` to use Firebase App Check and
+/// fetching the App Check token.
+NS_AVAILABLE_IOS(14)
+@protocol GIDAppCheckProvider <NSObject>
+
+/// Creates the instance of this App Check wrapper class.
+///
+/// @param tokenFetcher The instance performing the Firebase App Check token requests. If `provider`
+///     is nil, then we default to `FIRAppCheck`.
+/// @param userDefaults The instance of `NSUserDefaults` that `GIDAppCheck` will use to store its
+///     preparation status. If nil, `GIDAppCheck` will use `-[NSUserDefaults standardUserDefaults]`.
+- (instancetype)initWithAppCheckTokenFetcher:(nullable id<GIDAppCheckTokenFetcher>)tokenFetcher
+                                userDefaults:(nullable NSUserDefaults *)userDefaults;
+
+/// Prewarms the library for App Check by asking Firebase App Check to generate the App Attest key
+/// id and perform the initial attestation process (if needed).
+///
+/// @param completion A `nullable` callback with a `nullable` `NSError` if preparation fails.
+- (void)prepareForAppCheckWithCompletion:(nullable void (^)(NSError * _Nullable error))completion;
+
+/// Fetches the limited use Firebase token.
+///
+/// @param completion A `nullable` callback with the `FIRAppCheckToken` if present, or an `NSError`
+///     otherwise.
+- (void)getLimitedUseTokenWithCompletion:(nullable void (^)(FIRAppCheckToken * _Nullable token,
+                                                            NSError * _Nullable error))completion;
+
+/// Whether or not the App Attest key ID created and the attestation object has been fetched.
+- (BOOL)isPrepared;
+
+@end
+
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+
+NS_ASSUME_NONNULL_END

+ 38 - 0
GoogleSignIn/Sources/GIDAppCheck/Implementations/GIDAppCheck.h

@@ -0,0 +1,38 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#import <TargetConditionals.h>
+
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+
+#import <Foundation/Foundation.h>
+#import "GoogleSignIn/Sources/GIDAppCheck/API/GIDAppCheckProvider.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@class FIRAppCheckToken;
+extern NSString *const kGIDAppCheckPreparedKey;
+
+NS_CLASS_AVAILABLE_IOS(14)
+@interface GIDAppCheck : NSObject <GIDAppCheckProvider>
+
+- (instancetype)init NS_UNAVAILABLE;
+
+@end
+
+NS_ASSUME_NONNULL_END
+
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST

+ 135 - 0
GoogleSignIn/Sources/GIDAppCheck/Implementations/GIDAppCheck.m

@@ -0,0 +1,135 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#import "GoogleSignIn/Sources/GIDAppCheck/Implementations/GIDAppCheck.h"
+#import "GoogleSignIn/Sources/GIDAppCheck/API/GIDAppCheckProvider.h"
+#import "GoogleSignIn/Sources/GIDAppCheckTokenFetcher/Implementations/FIRAppCheck+GIDAppCheckTokenFetcher.h"
+#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDAppCheckError.h"
+
+@import FirebaseAppCheck;
+
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+
+NSErrorDomain const kGIDAppCheckErrorDomain = @"com.google.GIDAppCheck";
+NSString *const kGIDAppCheckPreparedKey = @"com.google.GIDAppCheckPreparedKey";
+
+typedef void (^GIDAppCheckPrepareCompletion)(NSError * _Nullable);
+typedef void (^GIDAppCheckTokenCompletion)(FIRAppCheckToken * _Nullable, NSError * _Nullable);
+
+@interface GIDAppCheck ()
+
+@property(nonatomic, strong) id<GIDAppCheckTokenFetcher> tokenFetcher;
+@property(nonatomic, strong) dispatch_queue_t workerQueue;
+@property(nonatomic, strong) NSUserDefaults *userDefaults;
+@property(atomic, strong) NSMutableArray<GIDAppCheckPrepareCompletion> *prepareCompletions;
+@property(atomic) BOOL preparing;
+
+@end
+
+@implementation GIDAppCheck
+
+- (instancetype)initWithAppCheckTokenFetcher:(nullable id<GIDAppCheckTokenFetcher>)tokenFetcher
+                                userDefaults:(nullable NSUserDefaults *)userDefaults {
+  if (self = [super init]) {
+    _tokenFetcher = tokenFetcher ?: [FIRAppCheck appCheck];
+    _userDefaults = userDefaults ?: [NSUserDefaults standardUserDefaults];
+    _workerQueue = dispatch_queue_create("com.google.googlesignin.GIDAppCheckWorkerQueue", nil);
+    _prepareCompletions = [NSMutableArray array];
+    _preparing = NO;
+  }
+  return self;
+}
+
+- (BOOL)isPrepared {
+  return [self.userDefaults boolForKey:kGIDAppCheckPreparedKey];
+}
+
+- (void)prepareForAppCheckWithCompletion:(nullable GIDAppCheckPrepareCompletion)completion {
+  if (completion) {
+    @synchronized (self) {
+      [self.prepareCompletions addObject:completion];
+    }
+  }
+
+  @synchronized (self) {
+    if (self.preparing) {
+      return;
+    }
+
+    self.preparing = YES;
+  }
+
+  dispatch_async(self.workerQueue, ^{
+    NSArray * __block callbacks;
+
+    if ([self isPrepared]) {
+      NSArray *callbacks;
+      @synchronized (self) {
+        callbacks = [self.prepareCompletions copy];
+        [self.prepareCompletions removeAllObjects];
+        self.preparing = NO;
+      }
+
+      for (GIDAppCheckPrepareCompletion savedCompletion in callbacks) {
+        savedCompletion(nil);
+      }
+      return;
+    }
+
+    [self.tokenFetcher limitedUseTokenWithCompletion:^(FIRAppCheckToken * _Nullable token,
+                                                       NSError * _Nullable error) {
+      NSError * __block maybeError = error;
+      @synchronized (self) {
+        if (!token && !error) {
+          maybeError = [NSError errorWithDomain:kGIDAppCheckErrorDomain
+                                           code:kGIDAppCheckUnexpectedError
+                                       userInfo:nil];
+        }
+
+        if (token) {
+          [self.userDefaults setBool:YES forKey:kGIDAppCheckPreparedKey];
+        }
+
+        callbacks = [self.prepareCompletions copy];
+        [self.prepareCompletions removeAllObjects];
+        self.preparing = NO;
+      }
+
+
+      for (GIDAppCheckPrepareCompletion savedCompletion in callbacks) {
+        savedCompletion(maybeError);
+      }
+    }];
+  });
+}
+
+- (void)getLimitedUseTokenWithCompletion:(nullable GIDAppCheckTokenCompletion)completion {
+  dispatch_async(self.workerQueue, ^{
+    [self.tokenFetcher limitedUseTokenWithCompletion:^(FIRAppCheckToken * _Nullable token,
+                                                       NSError * _Nullable error) {
+      if (token) {
+        [self.userDefaults setBool:YES forKey:kGIDAppCheckPreparedKey];
+      }
+      if (completion) {
+        completion(token, error);
+      }
+    }];
+  });
+}
+
+@end
+
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST

+ 37 - 0
GoogleSignIn/Sources/GIDAppCheck/UI/GIDActivityIndicatorViewController.h

@@ -0,0 +1,37 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#import <TargetConditionals.h>
+
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+
+#import <UIKit/UIKit.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+NS_CLASS_AVAILABLE_IOS(14.0)
+/// A `UIViewController` presented onscreen to indicate to the user that GSI is performing blocking
+/// work.
+@interface GIDActivityIndicatorViewController : UIViewController
+
+/// The indicator view spinning on screen.
+@property(nonatomic, strong, readonly) UIActivityIndicatorView *activityIndicator;
+
+@end
+
+NS_ASSUME_NONNULL_END
+
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST

+ 42 - 0
GoogleSignIn/Sources/GIDAppCheck/UI/GIDActivityIndicatorViewController.m

@@ -0,0 +1,42 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#import "GIDActivityIndicatorViewController.h"
+
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+
+#import <TargetConditionals.h>
+
+@implementation GIDActivityIndicatorViewController
+
+- (void)viewDidLoad {
+  [super viewDidLoad];
+
+  _activityIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleLarge];
+  self.activityIndicator.translatesAutoresizingMaskIntoConstraints = NO;
+  [self.activityIndicator startAnimating];
+  [self.view addSubview:self.activityIndicator];
+
+  NSLayoutConstraint *centerX =
+      [self.activityIndicator.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor];
+  NSLayoutConstraint *centerY =
+      [self.activityIndicator.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor];
+  [NSLayoutConstraint activateConstraints:@[centerX, centerY]];
+}
+
+@end
+
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST

+ 37 - 0
GoogleSignIn/Sources/GIDAppCheckTokenFetcher/API/GIDAppCheckTokenFetcher.h

@@ -0,0 +1,37 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+NS_ASSUME_NONNULL_BEGIN
+
+@class FIRAppCheckToken;
+
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+
+NS_AVAILABLE_IOS(14)
+@protocol GIDAppCheckTokenFetcher <NSObject>
+
+/// Get the limited use `FIRAppCheckToken`.
+///
+/// @param completion A block that passes back the `FIRAppCheckToken` upon success or an error in
+///     the case of any failure.
+- (void)limitedUseTokenWithCompletion:(nullable void (^)(FIRAppCheckToken * _Nullable token,
+                                                         NSError * _Nullable error))completion;
+
+@end
+
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+
+NS_ASSUME_NONNULL_END

+ 29 - 0
GoogleSignIn/Sources/GIDAppCheckTokenFetcher/Implementations/FIRAppCheck+GIDAppCheckTokenFetcher.h

@@ -0,0 +1,29 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@import FirebaseAppCheck;
+#import "GoogleSignIn/Sources/GIDAppCheckTokenFetcher/API/GIDAppCheckTokenFetcher.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+
+@interface FIRAppCheck (FIRAppCheck_GIDAppCheckTokenFetcher) <GIDAppCheckTokenFetcher>
+@end
+
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+
+NS_ASSUME_NONNULL_END

+ 45 - 0
GoogleSignIn/Sources/GIDAppCheckTokenFetcher/Implementations/GIDAppCheckTokenFetcherFake.h

@@ -0,0 +1,45 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#import <TargetConditionals.h>
+#import <Foundation/Foundation.h>
+
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+#import "GoogleSignIn/Sources/GIDAppCheckTokenFetcher/API/GIDAppCheckTokenFetcher.h"
+
+@class FIRAppCheckToken;
+
+NS_ASSUME_NONNULL_BEGIN
+
+extern NSUInteger const kGIDAppCheckTokenFetcherTokenError;
+
+NS_CLASS_AVAILABLE_IOS(14)
+@interface GIDAppCheckTokenFetcherFake : NSObject <GIDAppCheckTokenFetcher>
+
+/// Creates an instance with the provided app check token and error.
+///
+/// This protocol is mainly used for testing purposes so that the token fetching from Firebase App
+/// Check can be faked.
+/// @param token The `FIRAppCheckToken` to pass into the completion called from
+/// `limitedUseTokenWithCompletion:`.
+/// @param error The `NSError` to pass into the completion called from
+/// `limitedUseTokenWithCompletion:`.
+- (instancetype)initWithAppCheckToken:(nullable FIRAppCheckToken *)token
+                                error:(nullable NSError *)error;
+
+@end
+
+NS_ASSUME_NONNULL_END
+
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST

+ 51 - 0
GoogleSignIn/Sources/GIDAppCheckTokenFetcher/Implementations/GIDAppCheckTokenFetcherFake.m

@@ -0,0 +1,51 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#import <TargetConditionals.h>
+
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+#import "GoogleSignIn/Sources/GIDAppCheckTokenFetcher/Implementations/GIDAppCheckTokenFetcherFake.h"
+
+@import FirebaseAppCheck;
+
+NSUInteger const kGIDAppCheckTokenFetcherTokenError = 1;
+
+@interface GIDAppCheckTokenFetcherFake ()
+
+@property(nonatomic, strong, nullable) FIRAppCheckToken *token;
+@property(nonatomic, strong, nullable) NSError *error;
+
+@end
+
+@implementation GIDAppCheckTokenFetcherFake
+
+- (instancetype)initWithAppCheckToken:(nullable FIRAppCheckToken *)token
+                                error:(nullable NSError *)error {
+  if (self = [super init]) {
+    _token = token;
+    _error = error;
+  }
+  return self;
+}
+
+- (void)limitedUseTokenWithCompletion:(void (^)(FIRAppCheckToken * _Nullable,
+                                                NSError * _Nullable))completion {
+  dispatch_async(dispatch_get_main_queue(), ^{
+    completion(self.token, self.error);
+  });
+}
+
+@end
+
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST

+ 1 - 0
GoogleSignIn/Sources/GIDGoogleUser.m

@@ -117,6 +117,7 @@ static NSTimeInterval const kMinimalTimeToExpire = 60.0;
   return _cachedConfiguration;
 }
 
+// TODO: Should the refresh tokens flow also use App Check? (mdmathias, 2023.05.23)
 - (void)refreshTokensIfNeededWithCompletion:(GIDGoogleUserCompletion)completion {
   if (!([self.accessToken.expirationDate timeIntervalSinceNow] < kMinimalTimeToExpire ||
       (self.idToken && [self.idToken.expirationDate timeIntervalSinceNow] < kMinimalTimeToExpire))) {

+ 156 - 37
GoogleSignIn/Sources/GIDSignIn.m

@@ -28,6 +28,10 @@
 #import "GoogleSignIn/Sources/GIDScopes.h"
 #import "GoogleSignIn/Sources/GIDSignInCallbackSchemes.h"
 #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+#import "FirebaseAppCheck/FIRAppCheckToken.h"
+#import "GoogleSignIn/Sources/GIDAppCheck/UI/GIDActivityIndicatorViewController.h"
+#import "GoogleSignIn/Sources/GIDAppCheck/Implementations/GIDAppCheck.h"
+#import "GoogleSignIn/Sources/GIDAppCheck/API/GIDAppCheckProvider.h"
 #import "GoogleSignIn/Sources/GIDAuthStateMigration.h"
 #import "GoogleSignIn/Sources/GIDEMMErrorHandler.h"
 #endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST
@@ -132,6 +136,12 @@ static NSString *const kIncludeGrantedScopesParameter = @"include_granted_scopes
 static NSString *const kLoginHintParameter = @"login_hint";
 static NSString *const kHostedDomainParameter = @"hd";
 
+// Parameters for auth and token exchange endpoints using App Attest.
+static NSString *const kClientAssertionParameter = @"client_assertion";
+static NSString *const kClientAssertionTypeParameter = @"client_assertion_type";
+static NSString *const kClientAssertionTypeParameterValue =
+    @"urn:ietf:params:oauth:client-assertion-type:appcheck";
+
 // Minimum time to expiration for a restored access token.
 static const NSTimeInterval kMinimumRestoredAccessTokenTimeToExpire = 600.0;
 
@@ -159,6 +169,9 @@ static NSString *const kConfigOpenIDRealmKey = @"GIDOpenIDRealm";
   // set when a sign-in flow is begun via |signInWithOptions:| when the options passed don't
   // represent a sign in continuation.
   GIDSignInInternalOptions *_currentOptions;
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+  id<GIDAppCheckProvider> _appCheck API_AVAILABLE(ios(14));
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST
   // AppAuth configuration object.
   OIDServiceConfiguration *_appAuthConfiguration;
   // AppAuth external user-agent session state.
@@ -443,11 +456,37 @@ static NSString *const kConfigOpenIDRealmKey = @"GIDOpenIDRealm";
   static dispatch_once_t once;
   static GIDSignIn *sharedInstance;
   dispatch_once(&once, ^{
-    sharedInstance = [[self alloc] initPrivate];
+    GTMKeychainStore *keychainStore =
+        [[GTMKeychainStore alloc] initWithItemName:kGTMAppAuthKeychainName];
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+    if (@available(iOS 14.0, *)) {
+      GIDAppCheck *appCheck = [[GIDAppCheck alloc] initWithAppCheckTokenFetcher:nil
+                                                                   userDefaults:nil];
+      sharedInstance = [[self alloc] initWithKeychainStore:keychainStore
+                                          appCheckProvider:appCheck];
+    }
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+    if (!sharedInstance) {
+      sharedInstance = [[self alloc] initWithKeychainStore:keychainStore];
+    }
   });
   return sharedInstance;
 }
 
+#pragma mark - Configuring and pre-warming
+
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+- (void)configureWithCompletion:(nullable void (^)(NSError * _Nullable))completion {
+  @synchronized(self) {
+    [_appCheck prepareForAppCheckWithCompletion:^(NSError * _Nullable error) {
+        if (completion) {
+          completion(error);
+        }
+    }];
+  }
+}
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+
 #pragma mark - Private methods
 
 - (instancetype)initWithKeychainStore:(GTMKeychainStore *)keychainStore {
@@ -470,14 +509,13 @@ static NSString *const kConfigOpenIDRealmKey = @"GIDOpenIDRealm";
     }
 
     NSString *authorizationEnpointURL = [NSString stringWithFormat:kAuthorizationURLTemplate,
-        [GIDSignInPreferences googleAuthorizationServer]];
+                                         [GIDSignInPreferences googleAuthorizationServer]];
     NSString *tokenEndpointURL = [NSString stringWithFormat:kTokenURLTemplate,
-        [GIDSignInPreferences googleTokenServer]];
+                                  [GIDSignInPreferences googleTokenServer]];
     _appAuthConfiguration = [[OIDServiceConfiguration alloc]
-        initWithAuthorizationEndpoint:[NSURL URLWithString:authorizationEnpointURL]
-                        tokenEndpoint:[NSURL URLWithString:tokenEndpointURL]];
+                             initWithAuthorizationEndpoint:[NSURL URLWithString:authorizationEnpointURL]
+                             tokenEndpoint:[NSURL URLWithString:tokenEndpointURL]];
     _keychainStore = keychainStore;
-
 #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
     // Perform migration of auth state from old (before 5.0) versions of the SDK if needed.
     GIDAuthStateMigration *migration =
@@ -491,11 +529,16 @@ static NSString *const kConfigOpenIDRealmKey = @"GIDOpenIDRealm";
   return self;
 }
 
-- (instancetype)initPrivate {
-  GTMKeychainStore *keychainStore =
-      [[GTMKeychainStore alloc] initWithItemName:kGTMAppAuthKeychainName];
-  return [self initWithKeychainStore:keychainStore];
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+- (instancetype)initWithKeychainStore:(GTMKeychainStore *)keychainStore
+                     appCheckProvider:(id<GIDAppCheckProvider>)appCheckProvider {
+  self = [self initWithKeychainStore:keychainStore];
+  if (self) {
+    _appCheck = appCheckProvider;
+  }
+  return self;
 }
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST
 
 // Does sanity check for parameters and then authenticates if necessary.
 - (void)signInWithOptions:(GIDSignInInternalOptions *)options {
@@ -557,11 +600,6 @@ static NSString *const kConfigOpenIDRealmKey = @"GIDOpenIDRealm";
 #pragma mark - Authentication flow
 
 - (void)authenticateInteractivelyWithOptions:(GIDSignInInternalOptions *)options {
-  GIDSignInCallbackSchemes *schemes =
-      [[GIDSignInCallbackSchemes alloc] initWithClientIdentifier:options.configuration.clientID];
-  NSURL *redirectURL = [NSURL URLWithString:[NSString stringWithFormat:@"%@:%@",
-                                             [schemes clientIdentifierScheme],
-                                             kBrowserCallbackPath]];
   NSString *emmSupport;
 #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
   emmSupport = [[self class] isOperatingSystemAtLeast9] ? kEMMVersion : nil;
@@ -569,7 +607,100 @@ static NSString *const kConfigOpenIDRealmKey = @"GIDOpenIDRealm";
   emmSupport = nil;
 #endif // TARGET_OS_MACCATALYST || TARGET_OS_OSX
 
-  NSMutableDictionary<NSString *, NSString *> *additionalParameters = [@{} mutableCopy];
+  [self authorizationRequestWithOptions:options
+                             completion:^(OIDAuthorizationRequest * _Nullable request,
+                                          NSError * _Nullable error) {
+    self->_currentAuthorizationFlow =
+        [OIDAuthorizationService presentAuthorizationRequest:request
+#if TARGET_OS_IOS || TARGET_OS_MACCATALYST
+                                    presentingViewController:options.presentingViewController
+#elif TARGET_OS_OSX
+                                       presentingWindow:options.presentingWindow
+#endif // TARGET_OS_OSX
+                                                    callback:
+                                                      ^(OIDAuthorizationResponse *_Nullable authorizationResponse,
+                                                        NSError *_Nullable error) {
+      [self processAuthorizationResponse:authorizationResponse
+                                   error:error
+                              emmSupport:emmSupport];
+    }];
+  }];
+}
+
+- (void)authorizationRequestWithOptions:(GIDSignInInternalOptions *)options completion:
+    (void (^)(OIDAuthorizationRequest *_Nullable request, NSError *_Nullable error))completion {
+  BOOL shouldCallCompletion = YES;
+  NSMutableDictionary<NSString *, NSString *> *additionalParameters =
+      [self additionalParametersFromOptions:options];
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+  if (@available(iOS 14.0, *)) {
+    if (_appCheck) {
+      shouldCallCompletion = NO;
+      GIDActivityIndicatorViewController *activityVC =
+          [[GIDActivityIndicatorViewController alloc] init];
+      [options.presentingViewController presentViewController:activityVC
+                                                     animated:true
+                                                   completion:^{
+        // Ensure that the activity indicator shows for at least 1/2 second to prevent "flashing"
+        // TODO: Re-implement per: https://github.com/google/GoogleSignIn-iOS/issues/329
+        dispatch_time_t halfSecond = dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_MSEC / 2);
+        dispatch_after(halfSecond, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
+          [self->_appCheck getLimitedUseTokenWithCompletion:
+              ^(FIRAppCheckToken * _Nullable token, NSError * _Nullable error) {
+            if (token) {
+              additionalParameters[kClientAssertionTypeParameter] =
+                  kClientAssertionTypeParameterValue;
+              additionalParameters[kClientAssertionParameter] = token.token;
+              OIDAuthorizationRequest *request =
+                  [self authorizationRequestWithOptions:options
+                                   additionalParameters:additionalParameters];
+              [activityVC.activityIndicator stopAnimating];
+              [activityVC dismissViewControllerAnimated:YES completion:nil];
+              completion(request, nil);
+              return;
+            }
+            [activityVC.activityIndicator stopAnimating];
+            [activityVC dismissViewControllerAnimated:YES completion:nil];
+            completion(nil, error);
+            return;
+          }];
+        });
+      }];
+    }
+  }
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+  if (shouldCallCompletion) {
+    OIDAuthorizationRequest *request = [self authorizationRequestWithOptions:options
+                                                        additionalParameters:additionalParameters];
+    completion(request, nil);
+  }
+}
+
+
+- (OIDAuthorizationRequest *)
+    authorizationRequestWithOptions:(GIDSignInInternalOptions *)options
+               additionalParameters:(NSDictionary<NSString *, NSString *> *)additionalParameters {
+  OIDAuthorizationRequest *request =
+      [[OIDAuthorizationRequest alloc] initWithConfiguration:_appAuthConfiguration
+                                                    clientId:options.configuration.clientID
+                                                      scopes:options.scopes
+                                                 redirectURL:[self redirectURLWithOptions:options]
+                                                responseType:OIDResponseTypeCode
+                                        additionalParameters:additionalParameters];
+  return request;
+}
+
+- (NSMutableDictionary<NSString *, NSString *> *)
+    additionalParametersFromOptions:(GIDSignInInternalOptions *)options {
+  NSString *emmSupport;
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+  emmSupport = [[self class] isOperatingSystemAtLeast9] ? kEMMVersion : nil;
+#elif TARGET_OS_MACCATALYST || TARGET_OS_OSX
+  emmSupport = nil;
+#endif // TARGET_OS_MACCATALYST || TARGET_OS_OSX
+
+  NSMutableDictionary<NSString *, NSString *> *additionalParameters =
+      [[NSMutableDictionary alloc] init];
   additionalParameters[kIncludeGrantedScopesParameter] = @"true";
   if (options.configuration.serverClientID) {
     additionalParameters[kAudienceParameter] = options.configuration.serverClientID;
@@ -592,27 +723,16 @@ static NSString *const kConfigOpenIDRealmKey = @"GIDOpenIDRealm";
   additionalParameters[kSDKVersionLoggingParameter] = GIDVersion();
   additionalParameters[kEnvironmentLoggingParameter] = GIDEnvironment();
 
-  OIDAuthorizationRequest *request =
-      [[OIDAuthorizationRequest alloc] initWithConfiguration:_appAuthConfiguration
-                                                    clientId:options.configuration.clientID
-                                                      scopes:options.scopes
-                                                 redirectURL:redirectURL
-                                                responseType:OIDResponseTypeCode
-                                        additionalParameters:additionalParameters];
+  return additionalParameters;
+}
 
-  _currentAuthorizationFlow = [OIDAuthorizationService
-      presentAuthorizationRequest:request
-#if TARGET_OS_IOS || TARGET_OS_MACCATALYST
-         presentingViewController:options.presentingViewController
-#elif TARGET_OS_OSX
-                 presentingWindow:options.presentingWindow
-#endif // TARGET_OS_OSX
-                        callback:^(OIDAuthorizationResponse *_Nullable authorizationResponse,
-                                   NSError *_Nullable error) {
-    [self processAuthorizationResponse:authorizationResponse
-                                 error:error
-                            emmSupport:emmSupport];
-  }];
+- (NSURL *)redirectURLWithOptions:(GIDSignInInternalOptions *)options {
+  GIDSignInCallbackSchemes *schemes =
+      [[GIDSignInCallbackSchemes alloc] initWithClientIdentifier:options.configuration.clientID];
+  NSURL *redirectURL = [NSURL URLWithString:[NSString stringWithFormat:@"%@:%@",
+                                             [schemes clientIdentifierScheme],
+                                             kBrowserCallbackPath]];
+  return redirectURL;
 }
 
 - (void)processAuthorizationResponse:(OIDAuthorizationResponse *)authorizationResponse
@@ -677,7 +797,6 @@ static NSString *const kConfigOpenIDRealmKey = @"GIDOpenIDRealm";
 
 // Perform authentication with the provided options.
 - (void)authenticateWithOptions:(GIDSignInInternalOptions *)options {
-
   // If this is an interactive flow, we're not going to try to restore any saved auth state.
   if (options.interactive) {
     [self authenticateInteractivelyWithOptions:options];

+ 9 - 4
GoogleSignIn/Sources/GIDSignIn_Private.h

@@ -29,6 +29,7 @@ NS_ASSUME_NONNULL_BEGIN
 @class GIDGoogleUser;
 @class GIDSignInInternalOptions;
 @class GTMKeychainStore;
+@protocol GIDAppCheckProvider;
 
 /// Represents a completion block that takes a `GIDSignInResult` on success or an error if the
 /// operation was unsuccessful.
@@ -44,12 +45,16 @@ typedef void (^GIDDisconnectCompletion)(NSError *_Nullable error);
 /// Redeclare |currentUser| as readwrite for internal use.
 @property(nonatomic, readwrite, nullable) GIDGoogleUser *currentUser;
 
-/// Private initializer for |GIDSignIn|.
-- (instancetype)initPrivate;
-
-/// Private initializer taking a `GTMKeychainStore` to use during tests.
+/// Private initializer taking a `GTMKeychainStore`.
 - (instancetype)initWithKeychainStore:(GTMKeychainStore *)keychainStore;
 
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+/// Private initializer taking a `GTMKeychainStore` and `GIDAppCheckProvider`.
+- (instancetype)initWithKeychainStore:(GTMKeychainStore *)keychainStore
+                     appCheckProvider:(id<GIDAppCheckProvider>)appCheckProvider
+API_AVAILABLE(ios(14));
+#endif // TARGET_OS_IOS || !TARGET_OS_MACCATALYST
+
 /// Authenticates with extra options.
 - (void)signInWithOptions:(GIDSignInInternalOptions *)options;
 

+ 35 - 0
GoogleSignIn/Sources/Public/GoogleSignIn/GIDAppCheckError.h

@@ -0,0 +1,35 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#import <TargetConditionals.h>
+
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+
+#import <Foundation/Foundation.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// The error domain for `NSError`s returned by the Google Sign-In SDK related to App Check.
+extern NSErrorDomain const kGIDAppCheckErrorDomain;
+
+/// A list of potential error codes returned from the Google Sign-In SDK during App Check.
+typedef NS_ERROR_ENUM(kGIDAppCheckErrorDomain, GIDAppCheckErrorCode) {
+  /// An unexpected error was encountered.
+  kGIDAppCheckUnexpectedError = 1,
+};
+NS_ASSUME_NONNULL_END
+
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST

+ 23 - 6
GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h

@@ -26,6 +26,7 @@
 @class GIDConfiguration;
 @class GIDGoogleUser;
 @class GIDSignInResult;
+@protocol GIDAppCheckProvider;
 
 NS_ASSUME_NONNULL_BEGIN
 
@@ -66,6 +67,22 @@ typedef NS_ERROR_ENUM(kGIDSignInErrorDomain, GIDSignInErrorCode) {
 /// The active configuration for this instance of `GIDSignIn`.
 @property(nonatomic, nullable) GIDConfiguration *configuration;
 
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+
+/// Configures `GIDSignIn` for use.
+///
+/// @param completion A nullable callback block passing back any error arising from the
+/// configuration process if any exists.
+///
+/// Call this method on `GIDSignIn` prior to use and as early as possible. This method generates App
+/// Attest key IDs and the attestation object eagerly to minimize latency later on during the sign
+/// in or add scopes flows.
+- (void)configureWithCompletion:(nullable void (^)(NSError * _Nullable error))completion
+API_AVAILABLE(ios(14))
+NS_SWIFT_NAME(configureWithCompletion(completion:));
+
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+
 /// Unavailable. Use the `sharedInstance` property to instantiate `GIDSignIn`.
 /// :nodoc:
 + (instancetype)new NS_UNAVAILABLE;
@@ -139,9 +156,9 @@ typedef NS_ERROR_ENUM(kGIDSignInErrorDomain, GIDSignInErrorCode) {
 - (void)signInWithPresentingViewController:(UIViewController *)presentingViewController
                                       hint:(nullable NSString *)hint
                                 completion:
-    (nullable void (^)(GIDSignInResult *_Nullable signInResult,
-                       NSError *_Nullable error))completion
-    NS_EXTENSION_UNAVAILABLE("The sign-in flow is not supported in App Extensions.");
+(nullable void (^)(GIDSignInResult *_Nullable signInResult,
+                   NSError *_Nullable error))completion
+NS_EXTENSION_UNAVAILABLE("The sign-in flow is not supported in App Extensions.");
 
 /// Starts an interactive sign-in flow on iOS using the provided hint and additional scopes.
 ///
@@ -161,9 +178,9 @@ typedef NS_ERROR_ENUM(kGIDSignInErrorDomain, GIDSignInErrorCode) {
                                       hint:(nullable NSString *)hint
                           additionalScopes:(nullable NSArray<NSString *> *)additionalScopes
                                 completion:
-    (nullable void (^)(GIDSignInResult *_Nullable signInResult,
-                       NSError *_Nullable error))completion
-    NS_EXTENSION_UNAVAILABLE("The sign-in flow is not supported in App Extensions.");
+(nullable void (^)(GIDSignInResult *_Nullable signInResult,
+                   NSError *_Nullable error))completion
+NS_EXTENSION_UNAVAILABLE("The sign-in flow is not supported in App Extensions.");
 
 #elif TARGET_OS_OSX
 

+ 3 - 0
GoogleSignIn/Sources/Public/GoogleSignIn/GoogleSignIn.h

@@ -15,6 +15,9 @@
  */
 #import <TargetConditionals.h>
 
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+#import "GIDAppCheckError.h"
+#endif
 #import "GIDConfiguration.h"
 #import "GIDGoogleUser.h"
 #import "GIDProfileData.h"

+ 185 - 0
GoogleSignIn/Tests/Unit/GIDAppCheckTest.m

@@ -0,0 +1,185 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#import <TargetConditionals.h>
+
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+
+#import <XCTest/XCTest.h>
+#import "FirebaseAppCheck/FIRAppCheckToken.h"
+#import "GoogleSignIn/Sources/GIDAppCheck/Implementations/GIDAppCheck.h"
+#import "GoogleSignIn/Sources/GIDAppCheckTokenFetcher/Implementations/GIDAppCheckTokenFetcherFake.h"
+#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDAppCheckError.h"
+
+static NSUInteger const timeout = 1;
+static NSString *const kUserDefaultsSuiteName = @"GIDAppCheckKeySuiteName";
+
+NS_CLASS_AVAILABLE_IOS(14)
+@interface GIDAppCheckTest : XCTestCase
+
+@property(nonatomic, strong) NSUserDefaults *userDefaults;
+
+@end
+
+@implementation GIDAppCheckTest
+
+- (void)setUp {
+  [super setUp];
+  _userDefaults = [[NSUserDefaults alloc] initWithSuiteName:kUserDefaultsSuiteName];
+}
+
+- (void)tearDown {
+  [super tearDown];
+  [self.userDefaults removeObjectForKey:kGIDAppCheckPreparedKey];
+  [self.userDefaults removeSuiteNamed:kUserDefaultsSuiteName];
+}
+
+- (void)testGetLimitedUseTokenFailure {
+  XCTestExpectation *tokenFailExpectation =
+      [self expectationWithDescription:@"App check token fail"];
+  NSError *expectedError = [NSError errorWithDomain:kGIDAppCheckErrorDomain
+                                               code:kGIDAppCheckTokenFetcherTokenError
+                                           userInfo:nil];
+  GIDAppCheckTokenFetcherFake *tokenFetcher =
+      [[GIDAppCheckTokenFetcherFake alloc] initWithAppCheckToken:nil error:expectedError];
+  GIDAppCheck *appCheck = [[GIDAppCheck alloc] initWithAppCheckTokenFetcher:tokenFetcher
+                                                               userDefaults:self.userDefaults];
+
+  [appCheck getLimitedUseTokenWithCompletion:^(FIRAppCheckToken * _Nullable token,
+                                               NSError * _Nullable error) {
+    XCTAssertNil(token);
+    XCTAssertEqualObjects(expectedError, error);
+    [tokenFailExpectation fulfill];
+  }];
+
+  [self waitForExpectations:@[tokenFailExpectation] timeout:timeout];
+}
+
+- (void)testIsPreparedError {
+  XCTestExpectation *notAlreadyPreparedExpectation =
+      [self expectationWithDescription:@"App check not already prepared error"];
+
+  FIRAppCheckToken *expectedToken = [[FIRAppCheckToken alloc] initWithToken:@"foo"
+                                                             expirationDate:[NSDate distantFuture]];
+  // It doesn't matter what we pass for the error since we will check `isPrepared` and make one
+  GIDAppCheckTokenFetcherFake *tokenFetcher =
+      [[GIDAppCheckTokenFetcherFake alloc] initWithAppCheckToken:expectedToken error:nil];
+  GIDAppCheck *appCheck = [[GIDAppCheck alloc] initWithAppCheckTokenFetcher:tokenFetcher
+                                                               userDefaults:self.userDefaults];
+
+  [appCheck prepareForAppCheckWithCompletion:^(NSError * _Nullable error) {
+    XCTAssertNil(error);
+    [notAlreadyPreparedExpectation fulfill];
+  }];
+
+  [self waitForExpectations:@[notAlreadyPreparedExpectation] timeout:timeout];
+
+  XCTestExpectation *alreadyPreparedExpectation =
+      [self expectationWithDescription:@"App check already prepared error"];
+
+  [appCheck prepareForAppCheckWithCompletion:^(NSError * _Nullable error) {
+    XCTAssertNil(error);
+    [alreadyPreparedExpectation fulfill];
+  }];
+
+  [self waitForExpectations:@[alreadyPreparedExpectation] timeout:timeout];
+
+  XCTAssertTrue([appCheck isPrepared]);
+}
+
+- (void)testGetLimitedUseTokenSucceeds {
+  XCTestExpectation *prepareExpectation =
+      [self expectationWithDescription:@"Prepare for App Check expectation"];
+  FIRAppCheckToken *expectedToken = [[FIRAppCheckToken alloc] initWithToken:@"foo"
+                                                             expirationDate:[NSDate distantFuture]];
+
+  GIDAppCheckTokenFetcherFake *tokenFetcher =
+      [[GIDAppCheckTokenFetcherFake alloc] initWithAppCheckToken:expectedToken error:nil];
+  GIDAppCheck *appCheck = [[GIDAppCheck alloc] initWithAppCheckTokenFetcher:tokenFetcher
+                                                               userDefaults:self.userDefaults];
+
+  [appCheck prepareForAppCheckWithCompletion:^(NSError * _Nullable error) {
+    XCTAssertNil(error);
+    [prepareExpectation fulfill];
+  }];
+
+  // Wait for preparation to complete to test `isPrepared`
+  [self waitForExpectations:@[prepareExpectation] timeout:timeout];
+
+  XCTAssertTrue(appCheck.isPrepared);
+
+  XCTestExpectation *getLimitedUseTokenSucceedsExpectation =
+      [self expectationWithDescription:@"getLimitedUseToken should succeed"];
+
+  [appCheck getLimitedUseTokenWithCompletion:^(FIRAppCheckToken * _Nullable token,
+                                               NSError * _Nullable error) {
+    XCTAssertNil(error);
+    XCTAssertNotNil(token);
+    XCTAssertEqualObjects(token, expectedToken);
+    [getLimitedUseTokenSucceedsExpectation fulfill];
+  }];
+
+  [self waitForExpectations:@[getLimitedUseTokenSucceedsExpectation] timeout:timeout];
+
+  XCTAssertTrue([appCheck isPrepared]);
+}
+
+- (void)testAsyncCompletions {
+  XCTestExpectation *firstPrepareExpectation =
+      [self expectationWithDescription:@"First async prepare for App Check expectation"];
+
+  XCTestExpectation *secondPrepareExpectation =
+      [self expectationWithDescription:@"Second async prepare for App Check expectation"];
+
+  FIRAppCheckToken *expectedToken = [[FIRAppCheckToken alloc] initWithToken:@"foo"
+                                                             expirationDate:[NSDate distantFuture]];
+
+  GIDAppCheckTokenFetcherFake *tokenFetcher =
+      [[GIDAppCheckTokenFetcherFake alloc] initWithAppCheckToken:expectedToken error:nil];
+  GIDAppCheck *appCheck = [[GIDAppCheck alloc] initWithAppCheckTokenFetcher:tokenFetcher
+                                                               userDefaults:self.userDefaults];
+
+  dispatch_async(dispatch_get_main_queue(), ^{
+    [appCheck prepareForAppCheckWithCompletion:^(NSError * _Nullable error) {
+      XCTAssertNil(error);
+      [firstPrepareExpectation fulfill];
+    }];
+  });
+
+  dispatch_async(dispatch_get_main_queue(), ^{
+    [appCheck prepareForAppCheckWithCompletion:^(NSError * _Nullable error) {
+      XCTAssertNil(error);
+      [secondPrepareExpectation fulfill];
+    }];
+  });
+
+  [self waitForExpectations:@[firstPrepareExpectation, secondPrepareExpectation] timeout:timeout];
+
+  XCTAssertTrue([appCheck isPrepared]);
+
+  // Simulate requesting later on after `appCheck` is prepared
+  XCTestExpectation *preparedExpectation =
+      [self expectationWithDescription:@"Prepared expectation"];
+
+  [appCheck prepareForAppCheckWithCompletion:^(NSError * _Nullable error) {
+    XCTAssertNil(error);
+    [preparedExpectation fulfill];
+  }];
+
+  [self waitForExpectations:@[preparedExpectation] timeout:timeout];
+}
+
+@end
+
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST

+ 90 - 15
GoogleSignIn/Tests/Unit/GIDSignInTest.m

@@ -34,6 +34,9 @@
 #import "GoogleSignIn/Sources/GIDSignInPreferences.h"
 
 #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+#import "FirebaseAppCheck/FIRAppCheckToken.h"
+#import "GoogleSignIn/Sources/GIDAppCheck/Implementations/GIDAppCheck.h"
+#import "GoogleSignIn/Sources/GIDAppCheckTokenFetcher/Implementations/GIDAppCheckTokenFetcherFake.h"
 #import "GoogleSignIn/Sources/GIDEMMErrorHandler.h"
 #endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST
 
@@ -79,6 +82,9 @@
     return YES;\
 }]
 
+/// `NSUserDefaults` suite name for testing with `GIDAppCheck`.
+static NSString *const kUserDefaultsSuiteName = @"GIDAppCheckKeySuiteName";
+
 static NSString * const kFakeGaiaID = @"123456789";
 static NSString * const kFakeIDToken = @"FakeIDToken";
 static NSString * const kClientId = @"FakeClientID";
@@ -261,6 +267,11 @@ static NSString *const kNewScope = @"newScope";
 
   // Status returned by saveAuthorization:toKeychainForName:
   BOOL _saveAuthorizationReturnValue;
+
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+  // Test userDefaults for use with `GIDAppCheck`
+  NSUserDefaults *_testUserDefaults;
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST
 }
 @end
 
@@ -325,12 +336,15 @@ static NSString *const kNewScope = @"newScope";
   [_fakeMainBundle fakeAllSchemesSupported];
 
   // Object under test
-  [[NSUserDefaults standardUserDefaults] setBool:YES
-                                          forKey:kAppHasRunBeforeKey];
+  [[NSUserDefaults standardUserDefaults] setBool:YES forKey:kAppHasRunBeforeKey];
 
   _signIn = [[GIDSignIn alloc] initWithKeychainStore:_keychainStore];
   _hint = nil;
 
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+  _testUserDefaults = [[NSUserDefaults alloc] initWithSuiteName:kUserDefaultsSuiteName];
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+
   __weak GIDSignInTest *weakSelf = self;
   _completion = ^(GIDSignInResult *_Nullable signInResult, NSError * _Nullable error) {
     GIDSignInTest *strongSelf = weakSelf;
@@ -357,6 +371,11 @@ static NSString *const kNewScope = @"newScope";
   OCMVerifyAll(_presentingWindow);
 #endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST
 
+  [[NSUserDefaults standardUserDefaults] removeObjectForKey:kAppHasRunBeforeKey];
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+  [_testUserDefaults removeObjectForKey:kGIDAppCheckPreparedKey];
+  [_testUserDefaults removeSuiteNamed:kUserDefaultsSuiteName];
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST
 
   [_fakeMainBundle stopFaking];
   [super tearDown];
@@ -364,14 +383,63 @@ static NSString *const kNewScope = @"newScope";
 
 #pragma mark - Tests
 
-- (void)testShareInstance {
-  GIDSignIn *signIn1 = GIDSignIn.sharedInstance;
-  GIDSignIn *signIn2 = GIDSignIn.sharedInstance;
-  XCTAssertTrue(signIn1 == signIn2, @"shared instance must be singleton");
+#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
+- (void)testConfigureSucceeds {
+  if (@available(iOS 14, *)) {
+    XCTestExpectation *configureSucceedsExpecation =
+    [self expectationWithDescription:@"Configure succeeds expectation"];
+
+    FIRAppCheckToken *token = [[FIRAppCheckToken alloc] initWithToken:@"foo"
+                                                       expirationDate:[NSDate distantFuture]];
+    GIDAppCheckTokenFetcherFake *tokenFetcher =
+        [[GIDAppCheckTokenFetcherFake alloc] initWithAppCheckToken:token error:nil];
+    GIDAppCheck *appCheckProvider =
+        [[GIDAppCheck alloc] initWithAppCheckTokenFetcher:tokenFetcher
+                                             userDefaults:_testUserDefaults];
+
+    GIDSignIn *signIn = [[GIDSignIn alloc] initWithKeychainStore:_keychainStore
+                                                appCheckProvider:appCheckProvider];
+    [signIn configureWithCompletion:^(NSError * _Nullable error) {
+      XCTAssertNil(error);
+      [configureSucceedsExpecation fulfill];
+    }];
+
+    [self waitForExpectations:@[configureSucceedsExpecation] timeout:1];
+    XCTAssertTrue(appCheckProvider.isPrepared);
+  }
+}
+
+- (void)testConfigureFailsNoTokenOrError {
+  if (@available(iOS 14, *)) {
+    XCTestExpectation *configureFailsExpecation =
+    [self expectationWithDescription:@"Configure fails expectation"];
+
+    GIDAppCheckTokenFetcherFake *tokenFetcher =
+        [[GIDAppCheckTokenFetcherFake alloc] initWithAppCheckToken:nil error:nil];
+    GIDAppCheck *appCheckProvider =
+        [[GIDAppCheck alloc] initWithAppCheckTokenFetcher:tokenFetcher
+                                             userDefaults:_testUserDefaults];
+
+    GIDSignIn *signIn = [[GIDSignIn alloc] initWithKeychainStore:_keychainStore
+                                                appCheckProvider:appCheckProvider];
+
+    // `configureWithCompletion:` should fail if neither a token or error is present
+    [signIn configureWithCompletion:^(NSError * _Nullable error) {
+      XCTAssertNotNil(error);
+      XCTAssertEqual(error.code, kGIDAppCheckUnexpectedError);
+      [configureFailsExpecation fulfill];
+    }];
+
+    [self waitForExpectations:@[configureFailsExpecation] timeout:1];
+    XCTAssertFalse(appCheckProvider.isPrepared);
+  }
 }
+#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST
 
-- (void)testInitPrivate {
-  GIDSignIn *signIn = [[GIDSignIn alloc] initPrivate];
+- (void)testInitWithKeychainStore {
+  GTMKeychainStore *store = [[GTMKeychainStore alloc] initWithItemName:@"foo"];
+  GIDSignIn *signIn;
+  signIn = [[GIDSignIn alloc] initWithKeychainStore:store];
   XCTAssertNotNil(signIn.configuration);
   XCTAssertEqual(signIn.configuration.clientID, kClientId);
   XCTAssertNil(signIn.configuration.serverClientID);
@@ -379,21 +447,26 @@ static NSString *const kNewScope = @"newScope";
   XCTAssertNil(signIn.configuration.openIDRealm);
 }
 
-- (void)testInitPrivate_noConfig {
+- (void)testInitWithKeychainStore_noConfig {
   [_fakeMainBundle fakeWithClientID:nil
                      serverClientID:nil
                        hostedDomain:nil
                         openIDRealm:nil];
-  GIDSignIn *signIn = [[GIDSignIn alloc] initPrivate];
+  GTMKeychainStore *store = [[GTMKeychainStore alloc] initWithItemName:@"foo"];
+  GIDSignIn *signIn;
+  signIn = [[GIDSignIn alloc] initWithKeychainStore:store];
   XCTAssertNil(signIn.configuration);
 }
 
-- (void)testInitPrivate_fullConfig {
+- (void)testInitWithKeychainStore_fullConfig {
   [_fakeMainBundle fakeWithClientID:kClientId
                      serverClientID:kServerClientId
                        hostedDomain:kFakeHostedDomain
                         openIDRealm:kOpenIDRealm];
-  GIDSignIn *signIn = [[GIDSignIn alloc] initPrivate];
+
+  GTMKeychainStore *store = [[GTMKeychainStore alloc] initWithItemName:@"foo"];
+  GIDSignIn *signIn;
+  signIn = [[GIDSignIn alloc] initWithKeychainStore:store];
   XCTAssertNotNil(signIn.configuration);
   XCTAssertEqual(signIn.configuration.clientID, kClientId);
   XCTAssertEqual(signIn.configuration.serverClientID, kServerClientId);
@@ -401,12 +474,14 @@ static NSString *const kNewScope = @"newScope";
   XCTAssertEqual(signIn.configuration.openIDRealm, kOpenIDRealm);
 }
 
-- (void)testInitPrivate_invalidConfig {
+- (void)testInitWithKeychainStore_invalidConfig {
   [_fakeMainBundle fakeWithClientID:@[ @"bad", @"config", @"values" ]
                      serverClientID:nil
                        hostedDomain:nil
                         openIDRealm:nil];
-  GIDSignIn *signIn = [[GIDSignIn alloc] initPrivate];
+  GTMKeychainStore *store = [[GTMKeychainStore alloc] initWithItemName:@"foo"];
+  GIDSignIn *signIn;
+  signIn = [[GIDSignIn alloc] initWithKeychainStore:store];
   XCTAssertNil(signIn.configuration);
 }
 
@@ -1494,7 +1569,7 @@ static NSString *const kNewScope = @"newScope";
                         authorizationResponse:SAVE_TO_ARG_BLOCK(updatedAuthorizationResponse)
                                   profileData:SAVE_TO_ARG_BLOCK(profileData)];
     } else {
-      [[[_user stub] andReturn:_user] alloc];
+      [[[_user expect] andReturn:_user] alloc];
       (void)[[[_user expect] andReturn:_user] initWithAuthState:SAVE_TO_ARG_BLOCK(authState)
                                                     profileData:SAVE_TO_ARG_BLOCK(profileData)];
     }

+ 2 - 2
GoogleSignInSwiftSupport.podspec

@@ -1,7 +1,7 @@
 Pod::Spec.new do |s|
   s.name = 'GoogleSignInSwiftSupport'
-  s.version = '7.0.0'
-  s.swift_version = '4.0'
+  s.version = '7.0.1'
+  s.swift_version = '5.0'
   s.summary = 'Adds Swift-focused support for Google Sign-In.'
   s.description = 'Additional Swift support for the Google Sign-In SDK.'
   s.homepage = 'https://developers.google.com/identity/sign-in/ios/'

+ 7 - 1
Package.swift

@@ -24,7 +24,7 @@ let package = Package(
   defaultLocalization: "en",
   platforms: [
     .macOS(.v10_15),
-    .iOS(.v10)
+    .iOS(.v11)
   ],
   products: [
     .library(
@@ -45,6 +45,10 @@ let package = Package(
       name: "AppAuth",
       url: "https://github.com/openid/AppAuth-iOS.git",
       "1.6.0" ..< "2.0.0"),
+    .package(
+      name: "Firebase",
+      url: "https://github.com/firebase/firebase-ios-sdk.git",
+      "10.0.0" ..< "11.0.0"),
     .package(
       name: "GTMAppAuth",
       url: "https://github.com/google/GTMAppAuth.git",
@@ -67,6 +71,7 @@ let package = Package(
       name: "GoogleSignIn",
       dependencies: [
         .product(name: "AppAuth", package: "AppAuth"),
+        .product(name: "FirebaseAppCheck", package: "Firebase"),
         .product(name: "GTMAppAuth", package: "GTMAppAuth"),
         .product(name: "GTMSessionFetcherCore", package: "GTMSessionFetcher"),
       ],
@@ -103,6 +108,7 @@ let package = Package(
         "GoogleSignIn",
         "OCMock",
         .product(name: "AppAuth", package: "AppAuth"),
+        .product(name: "FirebaseAppCheck", package: "Firebase"),
         .product(name: "GTMAppAuth", package: "GTMAppAuth"),
         .product(name: "GTMSessionFetcherCore", package: "GTMSessionFetcher"),
         .product(name: "GULMethodSwizzler", package: "GoogleUtilities"),

+ 434 - 0
Samples/Swift/AppAttestExample/AppAttestExample.xcodeproj/project.pbxproj

@@ -0,0 +1,434 @@
+// !$*UTF8*$!
+{
+	archiveVersion = 1;
+	classes = {
+	};
+	objectVersion = 56;
+	objects = {
+
+/* Begin PBXBuildFile section */
+		4D8DB53AAE2F7D0055DCEA7F /* Pods_AppAttestExample.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 91F3A930BB86D9E0648046BC /* Pods_AppAttestExample.framework */; };
+		738D5F732A26BC3B00A7F11B /* BirthdayLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 738D5F722A26BC3B00A7F11B /* BirthdayLoader.swift */; };
+		73A464042A1C3B3400BA8528 /* AppAttestExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73A464032A1C3B3400BA8528 /* AppAttestExampleApp.swift */; };
+		73A464062A1C3B3400BA8528 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73A464052A1C3B3400BA8528 /* ContentView.swift */; };
+		73A4640B2A1C3B3500BA8528 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 73A4640A2A1C3B3500BA8528 /* Preview Assets.xcassets */; };
+		73BC0EB22A57609D00C3DDE5 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 73BC0EB12A57609D00C3DDE5 /* GoogleService-Info.plist */; };
+		73BD4BB52A390CFE00A48E3C /* BirthdayAppCheckProviderFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73BD4BB42A390CFE00A48E3C /* BirthdayAppCheckProviderFactory.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+		1C96B5B2B34E31F1A1CEE95E /* Pods-AppAttestExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppAttestExample.release.xcconfig"; path = "Target Support Files/Pods-AppAttestExample/Pods-AppAttestExample.release.xcconfig"; sourceTree = "<group>"; };
+		73443A232A55F56900A4932E /* AppAttestExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AppAttestExample.entitlements; sourceTree = "<group>"; };
+		738D5F722A26BC3B00A7F11B /* BirthdayLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BirthdayLoader.swift; sourceTree = "<group>"; };
+		73A464002A1C3B3400BA8528 /* AppAttestExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppAttestExample.app; sourceTree = BUILT_PRODUCTS_DIR; };
+		73A464032A1C3B3400BA8528 /* AppAttestExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAttestExampleApp.swift; sourceTree = "<group>"; };
+		73A464052A1C3B3400BA8528 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
+		73A4640A2A1C3B3500BA8528 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
+		73BC0EB12A57609D00C3DDE5 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
+		73BD4BB42A390CFE00A48E3C /* BirthdayAppCheckProviderFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BirthdayAppCheckProviderFactory.swift; sourceTree = "<group>"; };
+		7D9832F2FFAF408698660CA8 /* Pods-AppAttestExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppAttestExample.debug.xcconfig"; path = "Target Support Files/Pods-AppAttestExample/Pods-AppAttestExample.debug.xcconfig"; sourceTree = "<group>"; };
+		91F3A930BB86D9E0648046BC /* Pods_AppAttestExample.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AppAttestExample.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+		73A463FD2A1C3B3400BA8528 /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				4D8DB53AAE2F7D0055DCEA7F /* Pods_AppAttestExample.framework in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+		6B1005926777EEB3C903F93A /* Pods */ = {
+			isa = PBXGroup;
+			children = (
+				7D9832F2FFAF408698660CA8 /* Pods-AppAttestExample.debug.xcconfig */,
+				1C96B5B2B34E31F1A1CEE95E /* Pods-AppAttestExample.release.xcconfig */,
+			);
+			path = Pods;
+			sourceTree = "<group>";
+		};
+		73A463F72A1C3B3400BA8528 = {
+			isa = PBXGroup;
+			children = (
+				73A464022A1C3B3400BA8528 /* AppAttestExample */,
+				73A464012A1C3B3400BA8528 /* Products */,
+				6B1005926777EEB3C903F93A /* Pods */,
+				A73FBC2B93918F4B411815A1 /* Frameworks */,
+			);
+			sourceTree = "<group>";
+		};
+		73A464012A1C3B3400BA8528 /* Products */ = {
+			isa = PBXGroup;
+			children = (
+				73A464002A1C3B3400BA8528 /* AppAttestExample.app */,
+			);
+			name = Products;
+			sourceTree = "<group>";
+		};
+		73A464022A1C3B3400BA8528 /* AppAttestExample */ = {
+			isa = PBXGroup;
+			children = (
+				73443A232A55F56900A4932E /* AppAttestExample.entitlements */,
+				73A464032A1C3B3400BA8528 /* AppAttestExampleApp.swift */,
+				73BD4BB42A390CFE00A48E3C /* BirthdayAppCheckProviderFactory.swift */,
+				73A464052A1C3B3400BA8528 /* ContentView.swift */,
+				738D5F722A26BC3B00A7F11B /* BirthdayLoader.swift */,
+				73BC0EB12A57609D00C3DDE5 /* GoogleService-Info.plist */,
+				73A464092A1C3B3500BA8528 /* Preview Content */,
+			);
+			path = AppAttestExample;
+			sourceTree = "<group>";
+		};
+		73A464092A1C3B3500BA8528 /* Preview Content */ = {
+			isa = PBXGroup;
+			children = (
+				73A4640A2A1C3B3500BA8528 /* Preview Assets.xcassets */,
+			);
+			path = "Preview Content";
+			sourceTree = "<group>";
+		};
+		A73FBC2B93918F4B411815A1 /* Frameworks */ = {
+			isa = PBXGroup;
+			children = (
+				91F3A930BB86D9E0648046BC /* Pods_AppAttestExample.framework */,
+			);
+			name = Frameworks;
+			sourceTree = "<group>";
+		};
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+		73A463FF2A1C3B3400BA8528 /* AppAttestExample */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 73A4640E2A1C3B3500BA8528 /* Build configuration list for PBXNativeTarget "AppAttestExample" */;
+			buildPhases = (
+				D6AEC62E9810AEFD4C28F50F /* [CP] Check Pods Manifest.lock */,
+				73A463FC2A1C3B3400BA8528 /* Sources */,
+				73A463FD2A1C3B3400BA8528 /* Frameworks */,
+				73A463FE2A1C3B3400BA8528 /* Resources */,
+				C031D9D83F25CB0CD2512F23 /* [CP] Copy Pods Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			name = AppAttestExample;
+			productName = AppAttestExample;
+			productReference = 73A464002A1C3B3400BA8528 /* AppAttestExample.app */;
+			productType = "com.apple.product-type.application";
+		};
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+		73A463F82A1C3B3400BA8528 /* Project object */ = {
+			isa = PBXProject;
+			attributes = {
+				BuildIndependentTargetsInParallel = 1;
+				LastSwiftUpdateCheck = 1430;
+				LastUpgradeCheck = 1430;
+				TargetAttributes = {
+					73A463FF2A1C3B3400BA8528 = {
+						CreatedOnToolsVersion = 14.3;
+					};
+				};
+			};
+			buildConfigurationList = 73A463FB2A1C3B3400BA8528 /* Build configuration list for PBXProject "AppAttestExample" */;
+			compatibilityVersion = "Xcode 14.0";
+			developmentRegion = en;
+			hasScannedForEncodings = 0;
+			knownRegions = (
+				en,
+				Base,
+			);
+			mainGroup = 73A463F72A1C3B3400BA8528;
+			productRefGroup = 73A464012A1C3B3400BA8528 /* Products */;
+			projectDirPath = "";
+			projectRoot = "";
+			targets = (
+				73A463FF2A1C3B3400BA8528 /* AppAttestExample */,
+			);
+		};
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+		73A463FE2A1C3B3400BA8528 /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				73A4640B2A1C3B3500BA8528 /* Preview Assets.xcassets in Resources */,
+				73BC0EB22A57609D00C3DDE5 /* GoogleService-Info.plist in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+		C031D9D83F25CB0CD2512F23 /* [CP] Copy Pods Resources */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-AppAttestExample/Pods-AppAttestExample-resources-${CONFIGURATION}-input-files.xcfilelist",
+			);
+			name = "[CP] Copy Pods Resources";
+			outputFileListPaths = (
+				"${PODS_ROOT}/Target Support Files/Pods-AppAttestExample/Pods-AppAttestExample-resources-${CONFIGURATION}-output-files.xcfilelist",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AppAttestExample/Pods-AppAttestExample-resources.sh\"\n";
+			showEnvVarsInLog = 0;
+		};
+		D6AEC62E9810AEFD4C28F50F /* [CP] Check Pods Manifest.lock */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputFileListPaths = (
+			);
+			inputPaths = (
+				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+				"${PODS_ROOT}/Manifest.lock",
+			);
+			name = "[CP] Check Pods Manifest.lock";
+			outputFileListPaths = (
+			);
+			outputPaths = (
+				"$(DERIVED_FILE_DIR)/Pods-AppAttestExample-checkManifestLockResult.txt",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+			showEnvVarsInLog = 0;
+		};
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+		73A463FC2A1C3B3400BA8528 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				738D5F732A26BC3B00A7F11B /* BirthdayLoader.swift in Sources */,
+				73A464062A1C3B3400BA8528 /* ContentView.swift in Sources */,
+				73BD4BB52A390CFE00A48E3C /* BirthdayAppCheckProviderFactory.swift in Sources */,
+				73A464042A1C3B3400BA8528 /* AppAttestExampleApp.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+		73A4640C2A1C3B3500BA8528 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_ENABLE_OBJC_WEAK = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_COMMA = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_STRICT_PROTOTYPES = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = dwarf;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				ENABLE_TESTABILITY = YES;
+				GCC_C_LANGUAGE_STANDARD = gnu11;
+				GCC_DYNAMIC_NO_PIC = NO;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_OPTIMIZATION_LEVEL = 0;
+				GCC_PREPROCESSOR_DEFINITIONS = (
+					"DEBUG=1",
+					"$(inherited)",
+				);
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				INFOPLIST_FILE = "";
+				IPHONEOS_DEPLOYMENT_TARGET = 16.4;
+				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+				MTL_FAST_MATH = YES;
+				ONLY_ACTIVE_ARCH = YES;
+				SDKROOT = iphoneos;
+				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+			};
+			name = Debug;
+		};
+		73A4640D2A1C3B3500BA8528 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_ENABLE_OBJC_WEAK = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_COMMA = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_STRICT_PROTOTYPES = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+				ENABLE_NS_ASSERTIONS = NO;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				GCC_C_LANGUAGE_STANDARD = gnu11;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				INFOPLIST_FILE = "";
+				IPHONEOS_DEPLOYMENT_TARGET = 16.4;
+				MTL_ENABLE_DEBUG_INFO = NO;
+				MTL_FAST_MATH = YES;
+				SDKROOT = iphoneos;
+				SWIFT_COMPILATION_MODE = wholemodule;
+				SWIFT_OPTIMIZATION_LEVEL = "-O";
+				VALIDATE_PRODUCT = YES;
+			};
+			name = Release;
+		};
+		73A4640F2A1C3B3500BA8528 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 7D9832F2FFAF408698660CA8 /* Pods-AppAttestExample.debug.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_ENTITLEMENTS = AppAttestExample/AppAttestExample.entitlements;
+				CODE_SIGN_IDENTITY = "Apple Development";
+				CODE_SIGN_STYLE = Manual;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_ASSET_PATHS = "\"AppAttestExample/Preview Content\"";
+				DEVELOPMENT_TEAM = "";
+				ENABLE_PREVIEWS = YES;
+				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_FILE = AppAttestExample/Info.plist;
+				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+				);
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.google.experimental0.dev;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				PROVISIONING_PROFILE_SPECIFIER = "";
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Debug;
+		};
+		73A464102A1C3B3500BA8528 /* Release */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 1C96B5B2B34E31F1A1CEE95E /* Pods-AppAttestExample.release.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_ENTITLEMENTS = AppAttestExample/AppAttestExample.entitlements;
+				CODE_SIGN_IDENTITY = "Apple Development";
+				CODE_SIGN_STYLE = Manual;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_ASSET_PATHS = "\"AppAttestExample/Preview Content\"";
+				DEVELOPMENT_TEAM = "";
+				ENABLE_PREVIEWS = YES;
+				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_FILE = AppAttestExample/Info.plist;
+				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+				);
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.google.experimental0.dev;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				PROVISIONING_PROFILE_SPECIFIER = "";
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Release;
+		};
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+		73A463FB2A1C3B3400BA8528 /* Build configuration list for PBXProject "AppAttestExample" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				73A4640C2A1C3B3500BA8528 /* Debug */,
+				73A4640D2A1C3B3500BA8528 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+		73A4640E2A1C3B3500BA8528 /* Build configuration list for PBXNativeTarget "AppAttestExample" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				73A4640F2A1C3B3500BA8528 /* Debug */,
+				73A464102A1C3B3500BA8528 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+/* End XCConfigurationList section */
+	};
+	rootObject = 73A463F82A1C3B3400BA8528 /* Project object */;
+}

+ 84 - 0
Samples/Swift/AppAttestExample/AppAttestExample.xcodeproj/xcshareddata/xcschemes/AppAttestExample.xcscheme

@@ -0,0 +1,84 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "1430"
+   version = "1.7">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "73A463FF2A1C3B3400BA8528"
+               BuildableName = "AppAttestExample.app"
+               BlueprintName = "AppAttestExample"
+               ReferencedContainer = "container:AppAttestExample.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      shouldAutocreateTestPlan = "YES">
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "73A463FF2A1C3B3400BA8528"
+            BuildableName = "AppAttestExample.app"
+            BlueprintName = "AppAttestExample"
+            ReferencedContainer = "container:AppAttestExample.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+      <EnvironmentVariables>
+         <EnvironmentVariable
+            key = "FIRAppCheckDebugToken"
+            value = "F40DF0E8-9CCC-46DF-AC01-43DE96FCEDD8"
+            isEnabled = "YES">
+         </EnvironmentVariable>
+      </EnvironmentVariables>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "73A463FF2A1C3B3400BA8528"
+            BuildableName = "AppAttestExample.app"
+            BlueprintName = "AppAttestExample"
+            ReferencedContainer = "container:AppAttestExample.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 8 - 0
Samples/Swift/AppAttestExample/AppAttestExample/AppAttestExample.entitlements

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>com.apple.developer.devicecheck.appattest-environment</key>
+	<string>production</string>
+</dict>
+</plist>

+ 54 - 0
Samples/Swift/AppAttestExample/AppAttestExample/AppAttestExampleApp.swift

@@ -0,0 +1,54 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import SwiftUI
+import FirebaseCore
+import FirebaseAppCheck
+import GoogleSignIn
+
+class AppDelegate: NSObject, UIApplicationDelegate {
+  func application(
+    _ application: UIApplication,
+    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil
+  ) -> Bool {
+    #if targetEnvironment(simulator)
+    let debugProvider = AppCheckDebugProviderFactory()
+    AppCheck.setAppCheckProviderFactory(debugProvider)
+    #else
+    AppCheck.setAppCheckProviderFactory(BirthdayAppCheckProviderFactory())
+    #endif
+    FirebaseApp.configure()
+
+    GIDSignIn.sharedInstance.configureWithCompletion { error in
+      if let error {
+        print("Error configuring `GIDSignIn` for Firebase App Check: \(error)")
+      }
+    }
+
+    return true
+  }
+}
+
+@main
+struct AppAttestExampleApp: App {
+  @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
+  
+  var body: some Scene {
+    WindowGroup {
+      ContentView()
+    }
+  }
+}

+ 25 - 0
Samples/Swift/AppAttestExample/AppAttestExample/BirthdayAppCheckProviderFactory.swift

@@ -0,0 +1,25 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import FirebaseCore
+import FirebaseAppCheck
+
+class BirthdayAppCheckProviderFactory: NSObject, AppCheckProviderFactory {
+  func createProvider(with app: FirebaseApp) -> AppCheckProvider? {
+    return AppAttestProvider(app: app)
+  }
+}
+

+ 111 - 0
Samples/Swift/AppAttestExample/AppAttestExample/BirthdayLoader.swift

@@ -0,0 +1,111 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import Foundation
+import GoogleSignIn
+
+class BirthdayLoader {
+  /// The scope required to read a user's birthday.
+  static let birthdayReadScope = "https://www.googleapis.com/auth/user.birthday.read"
+  private let baseUrlString = "https://people.googleapis.com/v1/people/me"
+  private let personFieldsQuery = URLQueryItem(name: "personFields", value: "birthdays")
+
+  private lazy var components: URLComponents? = {
+    var comps = URLComponents(string: baseUrlString)
+    comps?.queryItems = [personFieldsQuery]
+    return comps
+  }()
+
+  private lazy var request: URLRequest? = {
+    guard let components = components, let url = components.url else {
+      return nil
+    }
+    return URLRequest(url: url)
+  }()
+
+  private func sessionWithFreshToken(completion: @escaping (Result<URLSession, Error>) -> Void) {
+    GIDSignIn.sharedInstance.currentUser?.refreshTokensIfNeeded { user, error in
+      guard let token = user?.accessToken.tokenString else {
+        completion(.failure(.couldNotRefreshToken))
+        return
+      }
+      let config = URLSessionConfiguration.default
+      config.httpAdditionalHeaders = [
+        "Authorization": "Bearer \(token)"
+      ]
+      let session = URLSession(configuration: config)
+      completion(.success(session))
+    }
+  }
+
+  func requestBirthday(completion: @escaping (Result<Birthday, Error>) -> Void) {
+    guard let req = request else {
+      completion(.failure(Error.noRequest))
+      return
+    }
+    sessionWithFreshToken { sessionResult in
+      switch sessionResult {
+      case .success(let session):
+        let task = session.dataTask(with: req) { data, response, error in
+          guard let data else {
+            completion(.failure(Error.noData))
+            return
+          }
+          do {
+            let jsonData = try JSONSerialization.jsonObject(with: data)
+            guard let json = jsonData as? [String: Any] else {
+              completion(.failure(Error.jsonDataCannotCastToString))
+              return
+            }
+            guard let birthdays = json["birthdays"] as? [[String: Any]],
+                  let firstBday = birthdays.first?["date"] as? [String: Int],
+                  let day = firstBday["day"],
+                  let month = firstBday["month"] else {
+              completion(.failure(Error.noBirthday))
+              return
+            }
+            completion(.success(Birthday(day: day, month: month)))
+          } catch {
+            completion(.failure(Error.noJSON))
+          }
+        }
+        task.resume()
+      case .failure(let error):
+        completion(.failure(error))
+      }
+    }
+  }
+}
+
+extension BirthdayLoader {
+  enum Error: Swift.Error {
+    case noRequest
+    case noData
+    case noJSON
+    case jsonDataCannotCastToString
+    case noBirthday
+    case couldNotRefreshToken
+  }
+}
+
+struct Birthday: CustomStringConvertible {
+  let day: Int
+  let month: Int
+
+  var description: String {
+    return "Day: \(day); month: \(month)"
+  }
+}

+ 218 - 0
Samples/Swift/AppAttestExample/AppAttestExample/ContentView.swift

@@ -0,0 +1,218 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import SwiftUI
+import GoogleSignIn
+import GoogleSignInSwift
+
+struct ContentView: View {
+  @State private var userInfo = ""
+  @State private var shouldPresentAlert = false
+  @State private var shouldPresentBirthdayAlert = false
+  @State private var errorInfo: ErrorInfo?
+  @State private var birthday: Birthday?
+  private let birthdayLoader = BirthdayLoader()
+
+  var body: some View {
+    NavigationStack {
+      VStack {
+        HStack {
+          GoogleSignInButton(style: .wide) {
+            self.userInfo = ""
+            guard let rootViewController = self.rootViewController else {
+              print("No root view controller")
+              return
+            }
+            GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController) { result, error in
+              guard let result else {
+                print("Error signing in: \(String(describing: error))")
+                return
+              }
+              print("Successfully signed in user")
+              self.userInfo = result.user.profile?.json ?? ""
+            }
+          }
+          .toolbar {
+            ToolbarItem(placement: .navigationBarTrailing) {
+              Button(NSLocalizedString("Sign out", comment: "sign out")) {
+                GIDSignIn.sharedInstance.signOut()
+                clearState()
+              }
+              .disabled(userInfo.isEmpty)
+            }
+            ToolbarItem(placement: .navigationBarLeading) {
+              Button(NSLocalizedString("Disconnect", comment: "disconnect")) {
+                GIDSignIn.sharedInstance.disconnect { error in
+                  if let error {
+                    print("Disconnection error: \(error)")
+                    return
+                  }
+                  print("Disconnected")
+                  clearState()
+                }
+              }
+              .disabled(shouldDisableDisconnect)
+            }
+            ToolbarItem(placement: .bottomBar) {
+              Button(NSLocalizedString("Add Birthday Scope", comment: "Add Scope Button")) {
+                guard let rvc = rootViewController else {
+                  print("No root view controller to use as presenter")
+                  return
+                }
+                GIDSignIn.sharedInstance.currentUser?.addScopes(
+                  [BirthdayLoader.birthdayReadScope],
+                  presenting: rvc
+                ) { result, error in
+                  shouldPresentAlert = true
+                  if let result {
+                    print("Successfully added scope: \(result)")
+                    errorInfo = ErrorInfo.success
+                    return
+                  }
+                  if let e = error {
+                    print("Failed to add scope: \(e)")
+                    if (e as NSError).code == GIDSignInError.scopesAlreadyGranted.rawValue {
+                      errorInfo = ErrorInfo(
+                        errorDescription: NSLocalizedString(
+                          "Did not add scope",
+                          comment: "no scope"
+                        ),
+                        failureReason: NSLocalizedString(
+                          "User already added scope.",
+                          comment: "already added scope"
+                        )
+                      )
+                    } else {
+                      errorInfo = ErrorInfo.unkownError
+                    }
+                    return
+                  }
+                }
+              }
+              .disabled(userInfo.isEmpty)
+            }
+            ToolbarItem(placement: .bottomBar) {
+              Button(NSLocalizedString("Fetch Birthday", comment: "birthday")) {
+                birthdayLoader.requestBirthday { birthdayResult in
+                  switch birthdayResult {
+                  case .success(let birthday):
+                    print(birthday)
+                    self.birthday = birthday
+                    errorInfo = ErrorInfo.birthday(with: birthday)
+                  case .failure(let error):
+                    print("Error fetching birthday: \(error)")
+                    errorInfo = ErrorInfo(
+                      errorDescription: (error as NSError).domain,
+                      failureReason: String((error as NSError).code)
+                    )
+                  }
+                  shouldPresentBirthdayAlert = true
+                }
+              }
+              .disabled(
+                !(GIDSignIn
+                  .sharedInstance
+                  .currentUser?
+                  .grantedScopes?
+                  .contains(BirthdayLoader.birthdayReadScope) ?? false)
+              )
+              .alert(
+                isPresented: $shouldPresentBirthdayAlert,
+                error: errorInfo) { info in
+                  Text(info.errorDescription ?? "Unknown birthday fetch error")
+                } message: { info in
+                  Text(info.birthday?.description ?? "No birthday")
+                }
+            }
+          }
+          .alert(
+            isPresented: $shouldPresentAlert,
+            error: errorInfo) { info in
+              Text(info.errorDescription ?? "Unknown Alert")
+            } message: { info in
+              Text(info.failureReason ?? "NA")
+            }
+        }
+        Text(userInfo)
+      }
+      .padding()
+    }
+  }
+}
+
+private extension ContentView {
+  var rootViewController: UIViewController? {
+    return UIApplication.shared.connectedScenes
+      .filter({ $0.activationState == .foregroundActive })
+      .compactMap { $0 as? UIWindowScene }
+      .compactMap { $0.keyWindow }
+      .first?.rootViewController
+  }
+
+  var shouldDisableDisconnect: Bool {
+    guard !userInfo.isEmpty else {
+      return true
+    }
+    return GIDSignIn.sharedInstance.currentUser?.grantedScopes?.isEmpty ?? false
+  }
+
+  func clearState() {
+    errorInfo = nil
+    shouldPresentAlert = false
+    userInfo = ""
+  }
+}
+
+private extension GIDProfileData {
+  var json: String {
+    """
+    success: {
+      Given Name: \(self.givenName ?? "None")
+      Family Name: \(self.familyName ?? "None")
+      Name: \(self.name)
+      Email: \(self.email)
+      Profile Photo: \(self.imageURL(withDimension: 1)?.absoluteString ?? "None");
+    }
+    """
+  }
+}
+
+fileprivate struct ErrorInfo: LocalizedError {
+  static var unkownError: ErrorInfo {
+    return ErrorInfo(
+      errorDescription: NSLocalizedString("Unknown Error!", comment: "unknown error"),
+      failureReason: NSLocalizedString("NA", comment: "NA error string")
+    )
+  }
+  static var success: ErrorInfo {
+    return ErrorInfo(
+      errorDescription: NSLocalizedString("Success!", comment: "no error"),
+      failureReason: NSLocalizedString("Added birthday read scope.", comment: "scope added")
+    )
+  }
+  static func birthday(with bday: Birthday) -> ErrorInfo {
+    return ErrorInfo(
+      errorDescription: NSLocalizedString("Success!", comment: "no error"),
+      failureReason: NSLocalizedString("Added birthday read scope.", comment: "scope added"),
+      birthday: bday
+    )
+  }
+  var errorDescription: String?
+  var failureReason: String?
+  var recoverySuggestion: String?
+  var helpAnchor: String?
+  var birthday: Birthday?
+}

+ 11 - 0
Samples/Swift/AppAttestExample/AppAttestExample/Preview Content/Preview Assets.xcassets/Assets/AccentColor.colorset/Contents.json

@@ -0,0 +1,11 @@
+{
+  "colors" : [
+    {
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 13 - 0
Samples/Swift/AppAttestExample/AppAttestExample/Preview Content/Preview Assets.xcassets/Assets/AppIcon.appiconset/Contents.json

@@ -0,0 +1,13 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "platform" : "ios",
+      "size" : "1024x1024"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 6 - 0
Samples/Swift/AppAttestExample/AppAttestExample/Preview Content/Preview Assets.xcassets/Assets/Contents.json

@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 6 - 0
Samples/Swift/AppAttestExample/AppAttestExample/Preview Content/Preview Assets.xcassets/Contents.json

@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 12 - 0
Samples/Swift/AppAttestExample/Podfile

@@ -0,0 +1,12 @@
+pod 'GoogleSignIn', :path => '../../../', :testspecs => ['unit']
+pod 'GoogleSignInSwiftSupport', :path => '../../../', :testspecs => ['unit']
+project 'AppAttestExample.xcodeproj'
+
+use_frameworks! :linkage => :static
+
+target 'AppAttestExample' do
+  platform :ios, '14.0'
+
+  pod 'FirebaseCore', '~> 10.0'
+  pod 'FirebaseAppCheck', '~> 10.0'
+end

+ 0 - 0
Samples/Swift/AppAttestExample/README.md