Skip to content
4 changes: 2 additions & 2 deletions WordPress/Classes/Services/AccountService+SocialService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ extension AccountService {
let remote = AccountServiceRemoteREST(wordPressComRestApi: api) else {
fatalError("Failed to initialize a valid remote via the default WordPress.com account.")
}
remote.connectToSocialService(service, serviceIDToken: token, success: success, failure: failure)
remote.connectToSocialService(service, serviceIDToken: token, oAuthClientID: ApiCredentials.client(), oAuthClientSecret: ApiCredentials.secret(), success: success, failure: failure)
}

/// Disconnect an account a social service via an ID token.
Expand All @@ -28,7 +28,7 @@ extension AccountService {
let remote = AccountServiceRemoteREST(wordPressComRestApi: api) else {
fatalError("Failed to initialize a valid remote via the default WordPress.com account.")
}
remote.disconnectFromSocialService(service, success: success, failure: failure)
remote.disconnectFromSocialService(service, oAuthClientID: ApiCredentials.client(), oAuthClientSecret: ApiCredentials.secret(), success: success, failure: failure)
}

}
15 changes: 14 additions & 1 deletion WordPress/Classes/Services/LoginFacade.h
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@
*/
- (void)requestOneTimeCodeWithLoginFields:(LoginFields *)loginFields;


/**
* Social login via google.
*
* @param googleIDToken A Google id_token.
*/
- (void)loginToWordPressDotComWithGoogleIDToken:(NSString *)googleIDToken;

/**
Expand Down Expand Up @@ -134,5 +138,14 @@
*/
- (void)finishedLoginWithGoogleIDToken:(NSString *)googleIDToken authToken:(NSString *)authToken;


/**
* Lets the delegate know that a social login attempt found a matching user, but
* their account has not been connected to the social service previously.
*
* @param email The email address that was matched.
*/
- (void)existingUserNeedsConnection:(NSString *)email;

@end

4 changes: 4 additions & 0 deletions WordPress/Classes/Services/LoginFacade.m
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ - (void)loginToWordPressDotComWithGoogleIDToken:(NSString *)googleIDToken
if ([self.delegate respondsToSelector:@selector(needsMultifactorCodeForUserID:andNonceInfo:)]) {
[self.delegate needsMultifactorCodeForUserID:userID andNonceInfo:nonceInfo];
}
} existingUserNeedsConnection: ^(NSString *email) {
if ([self.delegate respondsToSelector:@selector(existingUserNeedsConnection:)]) {
[self.delegate existingUserNeedsConnection: email];
}
} failure:^(NSError *error) {
[WPAppAnalytics track:WPAnalyticsStatLoginFailed error:error];
if ([self.delegate respondsToSelector:@selector(displayRemoteError:)]) {
Expand Down
1 change: 1 addition & 0 deletions WordPress/Classes/Services/WordPressComOAuthClientFacade.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- (void)authenticateWithGoogleIDToken:(NSString *)token
success:(void (^)(NSString *authToken))success
needsMultiFactor:(void (^)(NSInteger userID, SocialLogin2FANonceInfo *nonceInfo))needsMultifactor
existingUserNeedsConnection:(void (^)(NSString *email))existingUserNeedsConnection
failure:(void (^)(NSError *error))failure;

- (void)authenticateSocialLoginUser:(NSInteger)userID
Expand Down
3 changes: 2 additions & 1 deletion WordPress/Classes/Services/WordPressComOAuthClientFacade.m
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,11 @@ - (void)requestOneTimeCodeWithUsername:(NSString *)username
- (void)authenticateWithGoogleIDToken:(NSString *)token
success:(void (^)(NSString *authToken))success
needsMultiFactor:(void (^)(NSInteger userID, SocialLogin2FANonceInfo *nonceInfo))needsMultifactor
existingUserNeedsConnection:(void (^)(NSString *email))existingUserNeedsConnection
failure:(void (^)(NSError *error))failure
{
WordPressComOAuthClient *client = [WordPressComOAuthClient clientWithClientID:ApiCredentials.client secret:ApiCredentials.secret];
[client authenticateWithIDToken:token success:success needsMultifactor:needsMultifactor failure:failure];
[client authenticateWithIDToken:token success:success needsMultifactor:needsMultifactor existingUserNeedsConnection:existingUserNeedsConnection failure:failure];
}

- (void)authenticateSocialLoginUser:(NSInteger)userID
Expand Down
23 changes: 21 additions & 2 deletions WordPress/Classes/ViewRelated/NUX/LoginEmailViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ class LoginEmailViewController: LoginViewController, SigninKeyboardResponder {
awaitingGoogle = true
GIDSignIn.sharedInstance().disconnect()

// Flag this as a social sign in.
loginFields.meta.socialService = SocialServiceName.google

// Configure all the things and sign in.
GIDSignIn.sharedInstance().delegate = self
GIDSignIn.sharedInstance().uiDelegate = self
GIDSignIn.sharedInstance().clientID = ApiCredentials.googleLoginClientId()
Expand Down Expand Up @@ -282,9 +286,11 @@ class LoginEmailViewController: LoginViewController, SigninKeyboardResponder {


/// Validates what is entered in the various form fields and, if valid,
/// proceeds with the submit action.
/// proceeds with the submit action. Empties loginFields.meta.socialService as
/// social signin does not require form validation.
///
func validateForm() {
loginFields.meta.socialService = nil
displayError(message: "")
guard EmailFormatValidator.validate(string: loginFields.username) else {
assertionFailure("Form should not be submitted unless there is a valid looking email entered.")
Expand Down Expand Up @@ -424,6 +430,18 @@ extension LoginEmailViewController {
GIDSignIn.sharedInstance().disconnect()
}


func existingUserNeedsConnection(_ email: String!) {
// Disconnect now that we're done with Google.
GIDSignIn.sharedInstance().disconnect()

loginFields.username = email
loginFields.emailAddress = email

performSegue(withIdentifier: NUXAbstractViewController.SegueIdentifier.showWPComLogin, sender: self)
}


func needsMultifactorCode(forUserID userID: Int, andNonceInfo nonceInfo: SocialLogin2FANonceInfo!) {
// TODO: to be implemented.
}
Expand All @@ -439,9 +457,10 @@ extension LoginEmailViewController: GIDSignInDelegate {
return
}

// Store the email address.
// Store the email address and token.
loginFields.emailAddress = email
loginFields.username = email
loginFields.meta.socialServiceIDToken = token

configureViewLoading(true)

Expand Down
5 changes: 5 additions & 0 deletions WordPress/Classes/ViewRelated/NUX/LoginFields.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,9 @@ class LoginFieldsMeta: NSObject {
/// Flags whether a 2fa challenge had to be satisfied before a log in could be complete.
/// Included in analytics after a successful login.
var requiredMultifactor = false // A 2fa prompt was needed.

/// Identifies a social login and the service used.
var socialService: SocialServiceName?

var socialServiceIDToken: String?
}
11 changes: 11 additions & 0 deletions WordPress/Classes/ViewRelated/NUX/LoginViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,17 @@ extension LoginViewController: SigninWPComSyncHandler, LoginFacadeDelegate {

func finishedLogin(withUsername username: String!, authToken: String!, requiredMultifactorCode: Bool) {
syncWPCom(username, authToken: authToken, requiredMultifactor: requiredMultifactorCode)
guard let service = loginFields.meta.socialService, service == SocialServiceName.google,
let token = loginFields.meta.socialServiceIDToken else {
return
}

let accountService = AccountService(managedObjectContext: ContextManager.sharedInstance().mainContext)
accountService.connectToSocialService(service, serviceIDToken: token, success: {
// noop
}, failure: { error in
DDLogError(error.description)
})
}

func displayRemoteError(_ error: Error!) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ class LoginWPComViewController: LoginViewController, SigninKeyboardResponder {
}

func localizeControls() {
if let service = loginFields.meta.socialService, service == SocialServiceName.google {
instructionLabel?.text = NSLocalizedString("To proceed with this Google account, please first log in with your WordPress.com password. This will only be asked once.", comment: "")
} else {
instructionLabel?.text = NSLocalizedString("Enter the password for your WordPress.com account.", comment: "Instructional text shown when requesting the user's password for login.")
}

passwordField?.placeholder = NSLocalizedString("Password", comment: "Password placeholder")
passwordField?.accessibilityIdentifier = "Password"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,20 @@ extension AccountServiceRemoteREST {
/// - Parameters:
/// - service The name of the social service.
/// - token The OpenID Connect (JWT) ID token identifying the user on the social service.
/// - oAuthClientID The WPCOM REST API client ID.
/// - oAuthClientSecret The WPCOM REST API client secret.
/// - success The block that will be executed on success.
/// - failure The block that will be executed on failure.
public func connectToSocialService(_ service: SocialServiceName, serviceIDToken token: String, success:@escaping (() -> Void), failure:@escaping ((NSError) -> Void)) {
public func connectToSocialService(_ service: SocialServiceName, serviceIDToken token: String, oAuthClientID: String, oAuthClientSecret: String, success:@escaping (() -> Void), failure:@escaping ((NSError) -> Void)) {
guard let path = self.path(forEndpoint: "me/social-login/connect", with: .version_1_1) else {
// This should never fail but if it does we don't want to ignore the problem.
fatalError("There was a problem creating a valid path for the supplied endpoint and REST API version.")
}
let params = [
"client_id": oAuthClientID,
"client_secret": oAuthClientSecret,
"service": service.rawValue,
"id_token": token
"id_token": token,
] as [String: AnyObject]
wordPressComRestApi.POST(path, parameters: params, success: { (responseObject, httpResponse) in
success()
Expand All @@ -34,14 +38,18 @@ extension AccountServiceRemoteREST {
///
/// - Parameters:
/// - service The name of the social service.
/// - oAuthClientID The WPCOM REST API client ID.
/// - oAuthClientSecret The WPCOM REST API client secret.
/// - success The block that will be executed on success.
/// - failure The block that will be executed on failure.
public func disconnectFromSocialService(_ service: SocialServiceName, success:@escaping(() -> Void), failure:@escaping((NSError) -> Void)) {
public func disconnectFromSocialService(_ service: SocialServiceName, oAuthClientID: String, oAuthClientSecret: String, success:@escaping(() -> Void), failure:@escaping((NSError) -> Void)) {
guard let path = self.path(forEndpoint: "me/social-login/disconnect", with: .version_1_1) else {
// This should never fail but if it does we don't want to ignore the problem.
fatalError("There was a problem creating a valid path for the supplied endpoint and REST API version.")
}
let params = [
"client_id": oAuthClientID,
"client_secret": oAuthClientSecret,
"service": service.rawValue,
] as [String: AnyObject]
wordPressComRestApi.POST(path, parameters: params, success: { (responseObject, httpResponse) in
Expand Down
116 changes: 91 additions & 25 deletions WordPressKit/WordPressKit/WordPressComOAuthClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import CocoaLumberjack
case invalidRequest
case needsMultifactorCode
case invalidOneTimePassword
case socialLoginExistingUserUnconnected
}

/// `WordPressComOAuthClient` encapsulates the pattern of authenticating against WordPress.com OAuth2 service.
Expand All @@ -17,6 +18,7 @@ import CocoaLumberjack
///
public final class WordPressComOAuthClient: NSObject {

public static let WordPressComOAuthErrorResponseObjectKey = "WordPressComOAuthErrorResponseObjectKey"
public static let WordPressComOAuthErrorDomain = "WordPressComOAuthError"
public static let WordPressComOAuthBaseUrl = "https://public-api.wordpress.com/oauth2"
public static let WordPressComSocialLoginUrl = "https://wordpress.com/wp-login.php?action=social-login-endpoint&version=1.0"
Expand Down Expand Up @@ -149,6 +151,7 @@ public final class WordPressComOAuthClient: NSObject {
public func authenticateWithIDToken(_ token: String,
success: @escaping (_ authToken: String?) -> Void,
needsMultifactor: @escaping (_ userID: Int, _ nonceInfo: SocialLogin2FANonceInfo) -> Void,
existingUserNeedsConnection: @escaping (_ email: String) -> Void,
failure: @escaping (_ error: NSError) -> Void ) {
let parameters = [
"client_id": clientID,
Expand Down Expand Up @@ -190,7 +193,22 @@ public final class WordPressComOAuthClient: NSObject {
needsMultifactor(userID, nonceInfo)

}, failure: { (task, error) in
failure(error as NSError)
let err = error as NSError

// Inspect the error and handle the case of an existing user.
if err.code == WordPressComOAuthError.socialLoginExistingUserUnconnected.rawValue &&
err.domain == WordPressComOAuthClient.WordPressComOAuthErrorDomain {
// Get the responseObject from the userInfo dict.
// Extract the email address for the callback.
if let responseDict = err.userInfo[WordPressComOAuthClient.WordPressComOAuthErrorResponseObjectKey] as? [String: AnyObject],
let data = responseDict["data"] as? [String: AnyObject],
let email = data["email"] as? String {

existingUserNeedsConnection(email)
return
}
}
failure(err)
}
)
}
Expand Down Expand Up @@ -308,37 +326,85 @@ final class WordPressComOAuthResponseSerializer: AFJSONResponseSerializer {
super.init(coder: aDecoder)
}


/// Possible 400 errors:
/// - invalid_client: client_id is missing or wrong, it shouldn't happen
/// - unsupported_grant_type: client_id doesn't support password grants
/// - invalid_request: A required field is missing/malformed
/// - invalid_request: Authentication failed
/// - needs_2fa: Multifactor Authentication code is required
/// - user_exists: Returned by the social login endpoint if a wpcom user is found, but not connected to a social service.
///
let errorsMap = [
"invalid_client": WordPressComOAuthError.invalidClient,
"unsupported_grant_type": WordPressComOAuthError.unsupportedGrantType,
"invalid_request": WordPressComOAuthError.invalidRequest,
"needs_2fa": WordPressComOAuthError.needsMultifactorCode,
"invalid_otp": WordPressComOAuthError.invalidOneTimePassword,
"user_exists": WordPressComOAuthError.socialLoginExistingUserUnconnected,
]


/// Overridden to provide custom error handling. Some HTTP requests include
/// a response body even in a failure scenario. Since AFNetworking does not
/// pass a responseObject (if any) to a failure block this method ensures
/// it is available via an error's userInfo dictionary.
///
/// - Parameters:
/// - response: The URL response.
/// - data: Data returned from the request.
/// - error: A pointer to an error (if any).
/// - Returns: The response object or nil.
override func responseObject(for response: URLResponse?, data: Data?, error: NSErrorPointer) -> Any? {
let responseObject = super.responseObject(for: response, data: data, error: error)

guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 400,
let responseDictionary = responseObject as? [String: AnyObject],
let errorCode = responseDictionary["error"] as? String,
let errorDescription = responseDictionary["error_description"] as? String
else {
return responseObject as AnyObject?
guard let httpResponse = response as? HTTPURLResponse else {
return responseObject
}

/// Possible errors:
/// - invalid_client: client_id is missing or wrong, it shouldn't happen
/// - unsupported_grant_type: client_id doesn't support password grants
/// - invalid_request: A required field is missing/malformed
/// - invalid_request: Authentication failed
/// - needs_2fa: Multifactor Authentication code is required
///
let errorsMap = [
"invalid_client": WordPressComOAuthError.invalidClient,
"unsupported_grant_type": WordPressComOAuthError.unsupportedGrantType,
"invalid_request": WordPressComOAuthError.invalidRequest,
"needs_2fa": WordPressComOAuthError.needsMultifactorCode,
"invalid_otp" : WordPressComOAuthError.invalidOneTimePassword
]
// Handle known 400 errors.
if httpResponse.statusCode == 400 {
// REST API Error format
if let responseDictionary = responseObject as? [String: AnyObject],
let errorCode = responseDictionary["error"] as? String,
let errorDescription = responseDictionary["error_description"] as? String {

let mappedCode = errorsMap[errorCode]?.rawValue ?? WordPressComOAuthError.unknown.rawValue
error?.pointee = errorFor(errorCode: errorCode, errorDescription: errorDescription, responseObject: responseObject)
}

} else if httpResponse.statusCode == 409 {
// Social login user-exists error
if let responseDict = responseObject as? [String: AnyObject],
let data = responseDict["data"] as? [String: AnyObject],
let errors = data["errors"] as? NSArray,
let err = errors[0] as? [String: AnyObject],
let errorCode = err["code"] as? String,
let errorDescription = err["message"] as? String {

error?.pointee = errorFor(errorCode: errorCode, errorDescription: errorDescription, responseObject: responseObject)
}
}

error?.pointee = NSError(domain: WordPressComOAuthClient.WordPressComOAuthErrorDomain,
code: mappedCode,
userInfo: [NSLocalizedDescriptionKey: errorDescription])
return responseObject as AnyObject?
}


/// Creates an NSError from the supplied arguements. The response object is
/// added to the error's userInfo dictionary.
///
/// - Parameters:
/// - errorCode: A string representing the error code. This is not the same as an HTTP status code.
/// - errorDescription: A description of the error.
/// - responseObject: The responseObject (if any) that was passed with the error.
/// - Returns: An NSError.
func errorFor(errorCode: String, errorDescription: String, responseObject: Any?) -> NSError {
var userInfo:[String: AnyObject] = [NSLocalizedDescriptionKey: errorDescription as AnyObject]
if let responseObject = responseObject {
userInfo[WordPressComOAuthClient.WordPressComOAuthErrorResponseObjectKey] = responseObject as AnyObject
}
let mappedCode = errorsMap[errorCode]?.rawValue ?? WordPressComOAuthError.unknown.rawValue
return NSError(domain: WordPressComOAuthClient.WordPressComOAuthErrorDomain,
code: mappedCode,
userInfo: userInfo)
}
}