/* Copyright (c) 2011 Google Inc. * * 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 #if GTM_INCLUDE_OAUTH2 || !GDATA_REQUIRE_SERVICE_INCLUDES #if !TARGET_OS_IPHONE #import "GTMOAuth2WindowController.h" @interface GTMOAuth2WindowController () @property (nonatomic, retain) GTMOAuth2SignIn *signIn; @property (nonatomic, copy) NSURLRequest *initialRequest; @property (nonatomic, retain) GTMCookieStorage *cookieStorage; @property (nonatomic, retain) NSWindow *sheetModalForWindow; - (void)signInCommonForWindow:(NSWindow *)parentWindowOrNil; - (void)setupSheetTerminationHandling; - (void)destroyWindow; - (void)handlePrematureWindowClose; - (BOOL)shouldUseKeychain; - (void)signIn:(GTMOAuth2SignIn *)signIn displayRequest:(NSURLRequest *)request; - (void)signIn:(GTMOAuth2SignIn *)signIn finishedWithAuth:(GTMOAuth2Authentication *)auth error:(NSError *)error; - (void)sheetDidEnd:(NSWindow *)sheet returnCode:(NSInteger)returnCode contextInfo:(void *)contextInfo; - (void)handleCookiesForResponse:(NSURLResponse *)response; - (NSURLRequest *)addCookiesToRequest:(NSURLRequest *)request; @end const char *kKeychainAccountName = "OAuth"; @implementation GTMOAuth2WindowController // IBOutlets @synthesize keychainCheckbox = keychainCheckbox_, webView = webView_, webCloseButton = webCloseButton_, webBackButton = webBackButton_; // regular ivars @synthesize signIn = signIn_, initialRequest = initialRequest_, cookieStorage = cookieStorage_, sheetModalForWindow = sheetModalForWindow_, keychainItemName = keychainItemName_, initialHTMLString = initialHTMLString_, shouldAllowApplicationTermination = shouldAllowApplicationTermination_, externalRequestSelector = externalRequestSelector_, shouldPersistUser = shouldPersistUser_, userData = userData_, properties = properties_; #if !GTM_OAUTH2_SKIP_GOOGLE_SUPPORT // Create a controller for authenticating to Google services + (id)controllerWithScope:(NSString *)scope clientID:(NSString *)clientID clientSecret:(NSString *)clientSecret keychainItemName:(NSString *)keychainItemName resourceBundle:(NSBundle *)bundle { return [[[self alloc] initWithScope:scope clientID:clientID clientSecret:clientSecret keychainItemName:keychainItemName resourceBundle:bundle] autorelease]; } - (id)initWithScope:(NSString *)scope clientID:(NSString *)clientID clientSecret:(NSString *)clientSecret keychainItemName:(NSString *)keychainItemName resourceBundle:(NSBundle *)bundle { Class signInClass = [[self class] signInClass]; GTMOAuth2Authentication *auth; auth = [signInClass standardGoogleAuthenticationForScope:scope clientID:clientID clientSecret:clientSecret]; NSURL *authorizationURL = [signInClass googleAuthorizationURL]; return [self initWithAuthentication:auth authorizationURL:authorizationURL keychainItemName:keychainItemName resourceBundle:bundle]; } #endif // Create a controller for authenticating to any service + (id)controllerWithAuthentication:(GTMOAuth2Authentication *)auth authorizationURL:(NSURL *)authorizationURL keychainItemName:(NSString *)keychainItemName resourceBundle:(NSBundle *)bundle { return [[[self alloc] initWithAuthentication:auth authorizationURL:authorizationURL keychainItemName:keychainItemName resourceBundle:bundle] autorelease]; } - (id)initWithAuthentication:(GTMOAuth2Authentication *)auth authorizationURL:(NSURL *)authorizationURL keychainItemName:(NSString *)keychainItemName resourceBundle:(NSBundle *)bundle { if (bundle == nil) { bundle = [NSBundle mainBundle]; } NSString *nibName = [[self class] authNibName]; NSString *nibPath = [bundle pathForResource:nibName ofType:@"nib"]; self = [super initWithWindowNibPath:nibPath owner:self]; if (self != nil) { // use the supplied auth and OAuth endpoint URLs Class signInClass = [[self class] signInClass]; signIn_ = [[signInClass alloc] initWithAuthentication:auth authorizationURL:authorizationURL delegate:self webRequestSelector:@selector(signIn:displayRequest:) finishedSelector:@selector(signIn:finishedWithAuth:error:)]; keychainItemName_ = [keychainItemName copy]; // create local, temporary storage for WebKit cookies cookieStorage_ = [[GTMCookieStorage alloc] init]; } return self; } - (void)dealloc { [signIn_ release]; [initialRequest_ release]; [cookieStorage_ release]; [delegate_ release]; #if NS_BLOCKS_AVAILABLE [completionBlock_ release]; #endif [sheetModalForWindow_ release]; [keychainItemName_ release]; [initialHTMLString_ release]; [userData_ release]; [properties_ release]; [super dealloc]; } - (void)awakeFromNib { // load the requested initial sign-in page [self.webView setResourceLoadDelegate:self]; [self.webView setPolicyDelegate:self]; // the app may prefer some html other than blank white to be displayed // before the sign-in web page loads NSString *html = self.initialHTMLString; if ([html length] > 0) { [[self.webView mainFrame] loadHTMLString:html baseURL:nil]; } // hide the keychain checkbox if we're not supporting keychain BOOL hideKeychainCheckbox = ![self shouldUseKeychain]; const NSTimeInterval kJanuary2011 = 1293840000; BOOL isDateValid = ([[NSDate date] timeIntervalSince1970] > kJanuary2011); if (isDateValid) { // start the asynchronous load of the sign-in web page [[self.webView mainFrame] performSelector:@selector(loadRequest:) withObject:self.initialRequest afterDelay:0.01 inModes:[NSArray arrayWithObject:NSRunLoopCommonModes]]; } else { // clock date is invalid, so signing in would fail with an unhelpful error // from the server. Warn the user in an html string showing a watch icon, // question mark, and the system date and time. Hopefully this will clue // in brighter users, or at least let them make a useful screenshot to show // to developers. // // Even better is for apps to check the system clock and show some more // helpful, localized instructions for users; this is really a fallback. NSString *const htmlTemplate = @"
" @"⌚ ?
System Clock Incorrect
%@" @"
"; NSString *errHTML = [NSString stringWithFormat:htmlTemplate, [NSDate date]]; [[webView_ mainFrame] loadHTMLString:errHTML baseURL:nil]; hideKeychainCheckbox = YES; } #if DEBUG // Verify that Javascript is enabled BOOL hasJS = [[webView_ preferences] isJavaScriptEnabled]; NSAssert(hasJS, @"GTMOAuth2: Javascript is required"); #endif [keychainCheckbox_ setHidden:hideKeychainCheckbox]; } + (NSString *)authNibName { // subclasses may override this to specify a custom nib name return @"GTMOAuth2Window"; } #pragma mark - - (void)signInSheetModalForWindow:(NSWindow *)parentWindowOrNil delegate:(id)delegate finishedSelector:(SEL)finishedSelector { // check the selector on debug builds GTMAssertSelectorNilOrImplementedWithArgs(delegate, finishedSelector, @encode(GTMOAuth2WindowController *), @encode(GTMOAuth2Authentication *), @encode(NSError *), 0); delegate_ = [delegate retain]; finishedSelector_ = finishedSelector; [self signInCommonForWindow:parentWindowOrNil]; } #if NS_BLOCKS_AVAILABLE - (void)signInSheetModalForWindow:(NSWindow *)parentWindowOrNil completionHandler:(void (^)(GTMOAuth2Authentication *, NSError *))handler { completionBlock_ = [handler copy]; [self signInCommonForWindow:parentWindowOrNil]; } #endif - (void)signInCommonForWindow:(NSWindow *)parentWindowOrNil { self.sheetModalForWindow = parentWindowOrNil; hasDoneFinalRedirect_ = NO; hasCalledFinished_ = NO; [self.signIn startSigningIn]; } - (void)cancelSigningIn { // The user has explicitly asked us to cancel signing in // (so no further callback is required) hasCalledFinished_ = YES; [delegate_ autorelease]; delegate_ = nil; #if NS_BLOCKS_AVAILABLE [completionBlock_ autorelease]; completionBlock_ = nil; #endif // The signIn object's cancel method will close the window [self.signIn cancelSigningIn]; hasDoneFinalRedirect_ = YES; } - (IBAction)closeWindow:(id)sender { // dismiss the window/sheet before we call back the client [self destroyWindow]; [self handlePrematureWindowClose]; } #pragma mark SignIn callbacks - (void)signIn:(GTMOAuth2SignIn *)signIn displayRequest:(NSURLRequest *)request { // this is the signIn object's webRequest method, telling the controller // to either display the request in the webview, or close the window // // All web requests and all window closing goes through this routine #if DEBUG if ((isWindowShown_ && request != nil) || (!isWindowShown_ && request == nil)) { NSLog(@"Window state unexpected for request %@", [request URL]); return; } #endif if (request != nil) { // display the request self.initialRequest = request; NSWindow *parentWindow = self.sheetModalForWindow; if (parentWindow) { [self setupSheetTerminationHandling]; NSWindow *sheet = [self window]; [NSApp beginSheet:sheet modalForWindow:parentWindow modalDelegate:self didEndSelector:@selector(sheetDidEnd:returnCode:contextInfo:) contextInfo:nil]; } else { // modeless [self showWindow:self]; } isWindowShown_ = YES; } else { // request was nil [self destroyWindow]; } } - (void)setupSheetTerminationHandling { NSWindow *sheet = [self window]; SEL sel = @selector(setPreventsApplicationTerminationWhenModal:); if ([sheet respondsToSelector:sel]) { // setPreventsApplicationTerminationWhenModal is available in NSWindow // on 10.6 and later BOOL boolVal = !self.shouldAllowApplicationTermination; NSMethodSignature *sig = [sheet methodSignatureForSelector:sel]; NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:sig]; [invocation setSelector:sel]; [invocation setTarget:sheet]; [invocation setArgument:&boolVal atIndex:2]; [invocation invoke]; } } - (void)destroyWindow { // no request; close the window // Avoid more callbacks after the close happens, as the window // controller may be gone. [self.webView stopLoading:nil]; NSWindow *parentWindow = self.sheetModalForWindow; if (parentWindow) { [NSApp endSheet:[self window]]; } else { // defer closing the window, in case we're responding to some window event [[self window] performSelector:@selector(close) withObject:nil afterDelay:0.1 inModes:[NSArray arrayWithObject:NSRunLoopCommonModes]]; } isWindowShown_ = NO; } - (void)handlePrematureWindowClose { if (!hasDoneFinalRedirect_) { // tell the sign-in object to tell the user's finished method // that we're done [self.signIn windowWasClosed]; hasDoneFinalRedirect_ = YES; } } - (void)sheetDidEnd:(NSWindow *)sheet returnCode:(NSInteger)returnCode contextInfo:(void *)contextInfo { [sheet orderOut:self]; self.sheetModalForWindow = nil; } - (void)signIn:(GTMOAuth2SignIn *)signIn finishedWithAuth:(GTMOAuth2Authentication *)auth error:(NSError *)error { if (!hasCalledFinished_) { hasCalledFinished_ = YES; if (error == nil) { BOOL shouldUseKeychain = [self shouldUseKeychain]; if (shouldUseKeychain) { BOOL canAuthorize = auth.canAuthorize; BOOL isKeychainChecked = ([keychainCheckbox_ state] == NSOnState); NSString *keychainItemName = self.keychainItemName; if (isKeychainChecked && canAuthorize) { // save the auth params in the keychain [[self class] saveAuthToKeychainForName:keychainItemName authentication:auth]; } else { // remove the auth params from the keychain [[self class] removeAuthFromKeychainForName:keychainItemName]; } } } if (delegate_ && finishedSelector_) { SEL sel = finishedSelector_; NSMethodSignature *sig = [delegate_ methodSignatureForSelector:sel]; NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:sig]; [invocation setSelector:sel]; [invocation setTarget:delegate_]; [invocation setArgument:&self atIndex:2]; [invocation setArgument:&auth atIndex:3]; [invocation setArgument:&error atIndex:4]; [invocation invoke]; } [delegate_ autorelease]; delegate_ = nil; #if NS_BLOCKS_AVAILABLE if (completionBlock_) { completionBlock_(auth, error); // release the block here to avoid a retain loop on the controller [completionBlock_ autorelease]; completionBlock_ = nil; } #endif } } static Class gSignInClass = Nil; + (Class)signInClass { if (gSignInClass == Nil) { gSignInClass = [GTMOAuth2SignIn class]; } return gSignInClass; } + (void)setSignInClass:(Class)theClass { gSignInClass = theClass; } #pragma mark Token Revocation #if !GTM_OAUTH2_SKIP_GOOGLE_SUPPORT + (void)revokeTokenForGoogleAuthentication:(GTMOAuth2Authentication *)auth { [[self signInClass] revokeTokenForGoogleAuthentication:auth]; } #endif #pragma mark WebView methods - (NSURLRequest *)webView:(WebView *)sender resource:(id)identifier willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)redirectResponse fromDataSource:(WebDataSource *)dataSource { // override WebKit's cookie storage with our own to avoid cookie persistence // across sign-ins and interaction with the Safari browser's sign-in state [self handleCookiesForResponse:redirectResponse]; request = [self addCookiesToRequest:request]; if (!hasDoneFinalRedirect_) { hasDoneFinalRedirect_ = [self.signIn requestRedirectedToRequest:request]; if (hasDoneFinalRedirect_) { // signIn has told the window to close return nil; } } return request; } - (void)webView:(WebView *)sender resource:(id)identifier didReceiveResponse:(NSURLResponse *)response fromDataSource:(WebDataSource *)dataSource { // override WebKit's cookie storage with our own [self handleCookiesForResponse:response]; } - (void)webView:(WebView *)sender resource:(id)identifier didFinishLoadingFromDataSource:(WebDataSource *)dataSource { NSString *title = [sender stringByEvaluatingJavaScriptFromString:@"document.title"]; if ([title length] > 0) { [self.signIn titleChanged:title]; } [signIn_ cookiesChanged:(NSHTTPCookieStorage *)cookieStorage_]; } - (void)webView:(WebView *)sender resource:(id)identifier didFailLoadingWithError:(NSError *)error fromDataSource:(WebDataSource *)dataSource { [self.signIn loadFailedWithError:error]; } - (void)windowWillClose:(NSNotification *)note { if (isWindowShown_) { [self handlePrematureWindowClose]; } isWindowShown_ = NO; } - (void)webView:(WebView *)webView decidePolicyForNewWindowAction:(NSDictionary *)actionInformation request:(NSURLRequest *)request newFrameName:(NSString *)frameName decisionListener:(id)listener { SEL sel = self.externalRequestSelector; if (sel) { [delegate_ performSelector:sel withObject:self withObject:request]; } else { // default behavior is to open the URL in NSWorkspace's default browser NSURL *url = [request URL]; [[NSWorkspace sharedWorkspace] openURL:url]; } [listener ignore]; } #pragma mark Cookie management // Rather than let the WebView use Safari's default cookie storage, we intercept // requests and response to segregate and later discard cookies from signing in. // // This allows the application to actually sign out by discarding the auth token // rather than the user being kept signed in by the cookies. - (void)handleCookiesForResponse:(NSURLResponse *)response { if (self.shouldPersistUser) { // we'll let WebKit handle the cookies; they'll persist across apps // and across runs of this app return; } if ([response respondsToSelector:@selector(allHeaderFields)]) { // grab the cookies from the header as NSHTTPCookies and store them locally NSDictionary *headers = [(NSHTTPURLResponse *)response allHeaderFields]; if (headers) { NSURL *url = [response URL]; NSArray *cookies = [NSHTTPCookie cookiesWithResponseHeaderFields:headers forURL:url]; if ([cookies count] > 0) { [cookieStorage_ setCookies:cookies]; } } } } - (NSURLRequest *)addCookiesToRequest:(NSURLRequest *)request { if (self.shouldPersistUser) { // we'll let WebKit handle the cookies; they'll persist across apps // and across runs of this app return request; } // override WebKit's usual automatic storage of cookies NSMutableURLRequest *mutableRequest = [[request mutableCopy] autorelease]; [mutableRequest setHTTPShouldHandleCookies:NO]; // add our locally-stored cookies for this URL, if any NSArray *cookies = [cookieStorage_ cookiesForURL:[request URL]]; if ([cookies count] > 0) { NSDictionary *headers = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies]; NSString *cookieHeader = [headers objectForKey:@"Cookie"]; if (cookieHeader) { [mutableRequest setValue:cookieHeader forHTTPHeaderField:@"Cookie"]; } } return mutableRequest; } #pragma mark Keychain support + (NSString *)prefsKeyForName:(NSString *)keychainItemName { NSString *result = [@"OAuth2: " stringByAppendingString:keychainItemName]; return result; } + (BOOL)saveAuthToKeychainForName:(NSString *)keychainItemName authentication:(GTMOAuth2Authentication *)auth { [self removeAuthFromKeychainForName:keychainItemName]; // don't save unless we have a token that can really authorize requests if (!auth.canAuthorize) return NO; // make a response string containing the values we want to save NSString *password = [auth persistenceResponseString]; SecKeychainRef defaultKeychain = NULL; SecKeychainItemRef *dontWantItemRef= NULL; const char *utf8ServiceName = [keychainItemName UTF8String]; const char *utf8Password = [password UTF8String]; OSStatus err = SecKeychainAddGenericPassword(defaultKeychain, (UInt32) strlen(utf8ServiceName), utf8ServiceName, (UInt32) strlen(kKeychainAccountName), kKeychainAccountName, (UInt32) strlen(utf8Password), utf8Password, dontWantItemRef); BOOL didSucceed = (err == noErr); if (didSucceed) { // write to preferences that we have a keychain item (so we know later // that we can read from the keychain without raising a permissions dialog) NSString *prefKey = [self prefsKeyForName:keychainItemName]; NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; [defaults setBool:YES forKey:prefKey]; } return didSucceed; } + (BOOL)removeAuthFromKeychainForName:(NSString *)keychainItemName { SecKeychainRef defaultKeychain = NULL; SecKeychainItemRef itemRef = NULL; const char *utf8ServiceName = [keychainItemName UTF8String]; // we don't really care about the password here, we just want to // get the SecKeychainItemRef so we can delete it. OSStatus err = SecKeychainFindGenericPassword (defaultKeychain, (UInt32) strlen(utf8ServiceName), utf8ServiceName, (UInt32) strlen(kKeychainAccountName), kKeychainAccountName, 0, NULL, // ignore password &itemRef); if (err != noErr) { // failure to find is success return YES; } else { // found something, so delete it err = SecKeychainItemDelete(itemRef); CFRelease(itemRef); // remove our preference key NSString *prefKey = [self prefsKeyForName:keychainItemName]; NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; [defaults removeObjectForKey:prefKey]; return (err == noErr); } } #if !GTM_OAUTH2_SKIP_GOOGLE_SUPPORT + (GTMOAuth2Authentication *)authForGoogleFromKeychainForName:(NSString *)keychainItemName clientID:(NSString *)clientID clientSecret:(NSString *)clientSecret { Class signInClass = [self signInClass]; NSURL *tokenURL = [signInClass googleTokenURL]; NSString *redirectURI = [signInClass nativeClientRedirectURI]; GTMOAuth2Authentication *auth; auth = [GTMOAuth2Authentication authenticationWithServiceProvider:kGTMOAuth2ServiceProviderGoogle tokenURL:tokenURL redirectURI:redirectURI clientID:clientID clientSecret:clientSecret]; [GTMOAuth2WindowController authorizeFromKeychainForName:keychainItemName authentication:auth]; return auth; } #endif + (BOOL)authorizeFromKeychainForName:(NSString *)keychainItemName authentication:(GTMOAuth2Authentication *)newAuth { [newAuth setAccessToken:nil]; // before accessing the keychain, check preferences to verify that we've // previously saved a token to the keychain (so we don't needlessly raise // a keychain access permission dialog) NSString *prefKey = [self prefsKeyForName:keychainItemName]; NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; BOOL flag = [defaults boolForKey:prefKey]; if (!flag) { return NO; } BOOL didGetTokens = NO; SecKeychainRef defaultKeychain = NULL; const char *utf8ServiceName = [keychainItemName UTF8String]; SecKeychainItemRef *dontWantItemRef = NULL; void *passwordBuff = NULL; UInt32 passwordBuffLength = 0; OSStatus err = SecKeychainFindGenericPassword(defaultKeychain, (UInt32) strlen(utf8ServiceName), utf8ServiceName, (UInt32) strlen(kKeychainAccountName), kKeychainAccountName, &passwordBuffLength, &passwordBuff, dontWantItemRef); if (err == noErr && passwordBuff != NULL) { NSString *password = [[[NSString alloc] initWithBytes:passwordBuff length:passwordBuffLength encoding:NSUTF8StringEncoding] autorelease]; // free the password buffer that was allocated above SecKeychainItemFreeContent(NULL, passwordBuff); if (password != nil) { [newAuth setKeysForResponseString:password]; didGetTokens = YES; } } return didGetTokens; } #pragma mark User Properties - (void)setProperty:(id)obj forKey:(NSString *)key { if (obj == nil) { // User passed in nil, so delete the property [properties_ removeObjectForKey:key]; } else { // Be sure the property dictionary exists if (properties_ == nil) { [self setProperties:[NSMutableDictionary dictionary]]; } [properties_ setObject:obj forKey:key]; } } - (id)propertyForKey:(NSString *)key { id obj = [properties_ objectForKey:key]; // Be sure the returned pointer has the life of the autorelease pool, // in case self is released immediately return [[obj retain] autorelease]; } #pragma mark Accessors - (GTMOAuth2Authentication *)authentication { return self.signIn.authentication; } - (void)setNetworkLossTimeoutInterval:(NSTimeInterval)val { self.signIn.networkLossTimeoutInterval = val; } - (NSTimeInterval)networkLossTimeoutInterval { return self.signIn.networkLossTimeoutInterval; } - (BOOL)shouldUseKeychain { NSString *name = self.keychainItemName; return ([name length] > 0); } @end #endif // #if !TARGET_OS_IPHONE #endif // #if GTM_INCLUDE_OAUTH2 || !GDATA_REQUIRE_SERVICE_INCLUDES