diff --git a/WordPressAuthenticator.xcodeproj/project.pbxproj b/WordPressAuthenticator.xcodeproj/project.pbxproj index bfbe90dc3..0cbb414c1 100644 --- a/WordPressAuthenticator.xcodeproj/project.pbxproj +++ b/WordPressAuthenticator.xcodeproj/project.pbxproj @@ -20,6 +20,13 @@ 3F550D4E23DA429B007E5897 /* AppSelectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F550D4D23DA429B007E5897 /* AppSelectorTests.swift */; }; 3F550D5123DA4A9C007E5897 /* LinkMailPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F550D5023DA4A9C007E5897 /* LinkMailPresenter.swift */; }; 3F550D5323DA4AC6007E5897 /* URLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F550D5223DA4AC6007E5897 /* URLHandler.swift */; }; + 3F879FD5293A3AB6005C2B48 /* OAuthTokenRequestBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F879FD4293A3AB6005C2B48 /* OAuthTokenRequestBody.swift */; }; + 3F879FD7293A44F2005C2B48 /* OAuthTokenRequestBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F879FD6293A44F2005C2B48 /* OAuthTokenRequestBodyTests.swift */; }; + 3F879FD9293A48B2005C2B48 /* OAuthTokenResponseBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F879FD8293A48B2005C2B48 /* OAuthTokenResponseBody.swift */; }; + 3F879FDD293A500D005C2B48 /* URLRequest+OAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F879FDC293A500D005C2B48 /* URLRequest+OAuth.swift */; }; + 3F879FDF293A501D005C2B48 /* URLRequest+OAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F879FDE293A501D005C2B48 /* URLRequest+OAuthTests.swift */; }; + 3F879FE2293A53F5005C2B48 /* OAuthRequestBody+GoogleSignIn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F879FE0293A53CB005C2B48 /* OAuthRequestBody+GoogleSignIn.swift */; }; + 3F879FE4293A545C005C2B48 /* OAuthRequestBody+GoogleSignInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F879FE3293A545C005C2B48 /* OAuthRequestBody+GoogleSignInTests.swift */; }; 3F9439BE27D6F9B60067183A /* LoginPrologueViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F9439BD27D6F9B60067183A /* LoginPrologueViewController.swift */; }; 3FE8071529364C410088420C /* Result+ConvenienceInitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE8071429364C410088420C /* Result+ConvenienceInitTests.swift */; }; 3FE80717293650190088420C /* Result+ConvenienceInit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE80716293650190088420C /* Result+ConvenienceInit.swift */; }; @@ -27,6 +34,8 @@ 3FE8071D293652BB0088420C /* OAuthError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE8071C293652BB0088420C /* OAuthError.swift */; }; 3FE8071F2936558F0088420C /* URL+GoogleSignInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE8071E2936558F0088420C /* URL+GoogleSignInTests.swift */; }; 3FE8072129365F6D0088420C /* URL+GoogleSignIn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE8072029365F6D0088420C /* URL+GoogleSignIn.swift */; }; + 3FEC44F7293A0E4600EBDECF /* ProofKeyForCodeExchange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FEC44F6293A0E4600EBDECF /* ProofKeyForCodeExchange.swift */; }; + 3FEC44F9293A0F2900EBDECF /* ProofKeyForCodeExchangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FEC44F8293A0F2900EBDECF /* ProofKeyForCodeExchangeTests.swift */; }; 3FFF2FC123D7ED7C00D38C77 /* EmailClients.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3FFF2FC023D7ED7C00D38C77 /* EmailClients.plist */; }; 3FFF2FC323D7F53200D38C77 /* AppSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFF2FC223D7F53200D38C77 /* AppSelector.swift */; }; 4A1DEF4A29341B1F00322608 /* LoggingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 4A1DEF4829341B1F00322608 /* LoggingTests.m */; }; @@ -236,6 +245,13 @@ 3F550D4D23DA429B007E5897 /* AppSelectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSelectorTests.swift; sourceTree = ""; }; 3F550D5023DA4A9C007E5897 /* LinkMailPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkMailPresenter.swift; sourceTree = ""; }; 3F550D5223DA4AC6007E5897 /* URLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLHandler.swift; sourceTree = ""; }; + 3F879FD4293A3AB6005C2B48 /* OAuthTokenRequestBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthTokenRequestBody.swift; sourceTree = ""; }; + 3F879FD6293A44F2005C2B48 /* OAuthTokenRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthTokenRequestBodyTests.swift; sourceTree = ""; }; + 3F879FD8293A48B2005C2B48 /* OAuthTokenResponseBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthTokenResponseBody.swift; sourceTree = ""; }; + 3F879FDC293A500D005C2B48 /* URLRequest+OAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+OAuth.swift"; sourceTree = ""; }; + 3F879FDE293A501D005C2B48 /* URLRequest+OAuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+OAuthTests.swift"; sourceTree = ""; }; + 3F879FE0293A53CB005C2B48 /* OAuthRequestBody+GoogleSignIn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OAuthRequestBody+GoogleSignIn.swift"; sourceTree = ""; }; + 3F879FE3293A545C005C2B48 /* OAuthRequestBody+GoogleSignInTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OAuthRequestBody+GoogleSignInTests.swift"; sourceTree = ""; }; 3F9439BD27D6F9B60067183A /* LoginPrologueViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginPrologueViewController.swift; sourceTree = ""; }; 3FE8071429364C410088420C /* Result+ConvenienceInitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+ConvenienceInitTests.swift"; sourceTree = ""; }; 3FE80716293650190088420C /* Result+ConvenienceInit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+ConvenienceInit.swift"; sourceTree = ""; }; @@ -243,6 +259,8 @@ 3FE8071C293652BB0088420C /* OAuthError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthError.swift; sourceTree = ""; }; 3FE8071E2936558F0088420C /* URL+GoogleSignInTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+GoogleSignInTests.swift"; sourceTree = ""; }; 3FE8072029365F6D0088420C /* URL+GoogleSignIn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+GoogleSignIn.swift"; sourceTree = ""; }; + 3FEC44F6293A0E4600EBDECF /* ProofKeyForCodeExchange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProofKeyForCodeExchange.swift; sourceTree = ""; }; + 3FEC44F8293A0F2900EBDECF /* ProofKeyForCodeExchangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProofKeyForCodeExchangeTests.swift; sourceTree = ""; }; 3FFF2FC023D7ED7C00D38C77 /* EmailClients.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = EmailClients.plist; sourceTree = ""; }; 3FFF2FC223D7F53200D38C77 /* AppSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSelector.swift; sourceTree = ""; }; 4A1DEF4829341B1F00322608 /* LoggingTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = LoggingTests.m; sourceTree = ""; }; @@ -480,7 +498,11 @@ children = ( 3FE8071A2936515F0088420C /* ASWebAuthenticationSession+Utils.swift .swift */, 3FE8071C293652BB0088420C /* OAuthError.swift */, + 3F879FD4293A3AB6005C2B48 /* OAuthTokenRequestBody.swift */, + 3F879FD8293A48B2005C2B48 /* OAuthTokenResponseBody.swift */, + 3FEC44F6293A0E4600EBDECF /* ProofKeyForCodeExchange.swift */, 3FE80716293650190088420C /* Result+ConvenienceInit.swift */, + 3F879FDC293A500D005C2B48 /* URLRequest+OAuth.swift */, ); path = OAuth; sourceTree = ""; @@ -488,7 +510,10 @@ 3FE807192936504F0088420C /* OAuth */ = { isa = PBXGroup; children = ( + 3F879FD6293A44F2005C2B48 /* OAuthTokenRequestBodyTests.swift */, + 3FEC44F8293A0F2900EBDECF /* ProofKeyForCodeExchangeTests.swift */, 3FE8071429364C410088420C /* Result+ConvenienceInitTests.swift */, + 3F879FDE293A501D005C2B48 /* URLRequest+OAuthTests.swift */, ); path = OAuth; sourceTree = ""; @@ -496,6 +521,7 @@ 3FE8072229365F740088420C /* GoogleSignIn */ = { isa = PBXGroup; children = ( + 3F879FE0293A53CB005C2B48 /* OAuthRequestBody+GoogleSignIn.swift */, 3FE8072029365F6D0088420C /* URL+GoogleSignIn.swift */, ); path = GoogleSignIn; @@ -504,6 +530,7 @@ 3FE8072329365FC20088420C /* GoogleSignIn */ = { isa = PBXGroup; children = ( + 3F879FE3293A545C005C2B48 /* OAuthRequestBody+GoogleSignInTests.swift */, 3FE8071E2936558F0088420C /* URL+GoogleSignInTests.swift */, ); path = GoogleSignIn; @@ -1295,6 +1322,7 @@ files = ( CE73475624B77A3800A22660 /* SiteCredentialsViewController.swift in Sources */, EE633D02287560E50002DE03 /* UITableView+Helpers.swift in Sources */, + 3F879FD5293A3AB6005C2B48 /* OAuthTokenRequestBody.swift in Sources */, 982C8E7923021C20003F1BA0 /* LoginPrologueLoginMethodViewController.swift in Sources */, B5609144208A563800399AE4 /* LoginPrologueSignupMethodViewController.swift in Sources */, B56090D1208A4F5400399AE4 /* NUXViewController.swift in Sources */, @@ -1304,6 +1332,7 @@ CE1B18C920EEC2C200BECC3F /* SocialService.swift in Sources */, F12F9FB424D8A68E00771BCE /* AuthenticatorAnalyticsTracker.swift in Sources */, 988AD8A324CB839900BD045E /* TwoFAViewController.swift in Sources */, + 3F879FD9293A48B2005C2B48 /* OAuthTokenResponseBody.swift in Sources */, CE6BCD2E24A3A235001BCDC5 /* TextLabelTableViewCell.swift in Sources */, B56090D3208A4F5400399AE4 /* NUXLinkAuthViewController.swift in Sources */, B5609120208A555E00399AE4 /* SignupNavigationController.swift in Sources */, @@ -1313,6 +1342,7 @@ 02A526CF28A3A35D00FD1812 /* PasswordCoordinator.swift in Sources */, 98ED483624802F8F00992B2D /* GoogleAuthViewController.swift in Sources */, F5C817E72582B2F300BD5A3B /* UIPasteboard+Detect.swift in Sources */, + 3FEC44F7293A0E4600EBDECF /* ProofKeyForCodeExchange.swift in Sources */, B56090EA208A51D000399AE4 /* LoginFields+Validation.swift in Sources */, F1DE08CC24F4266A007AE6B3 /* StoredCredentialsAuthenticator.swift in Sources */, CE1B18CC20EEC32400BECC3F /* WordPressComCredentials.swift in Sources */, @@ -1335,6 +1365,7 @@ 3FE8071D293652BB0088420C /* OAuthError.swift in Sources */, B5609119208A555600399AE4 /* SiteInfoHeaderView.swift in Sources */, B560913E208A563800399AE4 /* SigninEditingState.swift in Sources */, + 3F879FDD293A500D005C2B48 /* URLRequest+OAuth.swift in Sources */, CE2D03E024E5DD4500D18942 /* UnifiedSignupViewController.swift in Sources */, 98CF18F7248725370047B66C /* GoogleSignupConfirmationViewController.swift in Sources */, 1A21EE9822832BC300C940C6 /* WordPressComOAuthClientFacade+Swift.swift in Sources */, @@ -1355,6 +1386,7 @@ B56090F9208A533200399AE4 /* WordPressAuthenticator+Events.swift in Sources */, CEDE0D93242011E000CB3345 /* NSObject+Helpers.swift in Sources */, 020DEF6428AA091100C85D51 /* MagicLinkRequester.swift in Sources */, + 3F879FE2293A53F5005C2B48 /* OAuthRequestBody+GoogleSignIn.swift in Sources */, 020BE74A23B0BD2E007FE54C /* WordPressAuthenticatorDisplayImages.swift in Sources */, B560913A208A563800399AE4 /* LoginLinkRequestViewController.swift in Sources */, B560910C208A54F800399AE4 /* WordPressComOAuthClientFacade.m in Sources */, @@ -1423,6 +1455,7 @@ B501C045208FC68700D1E58F /* LoginFieldsValidationTests.swift in Sources */, BA53D64824DFDF97001F1ABF /* WordPressSourceTagTests.swift in Sources */, 4A1DEF4B29341B1F00322608 /* LoggingTests.swift in Sources */, + 3F879FDF293A501D005C2B48 /* URLRequest+OAuthTests.swift in Sources */, D8610CEC2570A60C00A5DF27 /* NavigationToRootTests.swift in Sources */, BA53D64D24DFE4E6001F1ABF /* ModalViewControllerPresentingSpy.swift in Sources */, BA53D64624DFDE1D001F1ABF /* CredentialsTests.swift in Sources */, @@ -1430,14 +1463,17 @@ 3F550D4E23DA429B007E5897 /* AppSelectorTests.swift in Sources */, BA53D64B24DFE07D001F1ABF /* WordpressAuthenticatorProvider.swift in Sources */, 4A1DEF4A29341B1F00322608 /* LoggingTests.m in Sources */, + 3FEC44F9293A0F2900EBDECF /* ProofKeyForCodeExchangeTests.swift in Sources */, CE16177821B70C1A00B82A47 /* WordPressAuthenticatorDisplayTextTests.swift in Sources */, B501C048208FC79C00D1E58F /* LoginFacadeTests.m in Sources */, + 3F879FD7293A44F2005C2B48 /* OAuthTokenRequestBodyTests.swift in Sources */, 3108613125AFA4830022F75E /* PasteboardTests.swift in Sources */, 3FE8071F2936558F0088420C /* URL+GoogleSignInTests.swift in Sources */, D85C36F0256E118D00D56E34 /* NavigationToEnterAccountTests.swift in Sources */, D85C36E6256E0DDE00D56E34 /* NavigationToEnterSiteTests.swift in Sources */, D85C3882256E3FEC00D56E34 /* WordPressComSiteInfoTests.swift in Sources */, D8611A672576236800A5DF27 /* NavigateBackTests.swift in Sources */, + 3F879FE4293A545C005C2B48 /* OAuthRequestBody+GoogleSignInTests.swift in Sources */, F12F9FB824D8A7FC00771BCE /* AnalyticsTrackerTests.swift in Sources */, B501C046208FC6A700D1E58F /* WordPressAuthenticatorTests.swift in Sources */, ); diff --git a/WordPressAuthenticator/GoogleSignIn/OAuthRequestBody+GoogleSignIn.swift b/WordPressAuthenticator/GoogleSignIn/OAuthRequestBody+GoogleSignIn.swift new file mode 100644 index 000000000..20d9642d5 --- /dev/null +++ b/WordPressAuthenticator/GoogleSignIn/OAuthRequestBody+GoogleSignIn.swift @@ -0,0 +1,24 @@ +extension OAuthTokenRequestBody { + + static func googleSignInRequestBody( + clientId: String, + authCode: String, + pkce: ProofKeyForCodeExchange + ) -> Self { + .init( + clientId: clientId, + // "The client secret obtained from the API Console Credentials page." + // - https://developers.google.com/identity/protocols/oauth2/native-app#step-2:-send-a-request-to-googles-oauth-2.0-server + // + // There doesn't seem to be any secret for iOS app credentials. + // The process works with an empty string... + clientSecret: "", + code: authCode, + codeVerifier: pkce.codeVerifier, + // As defined in the OAuth 2.0 specification, this field's value must be set to authorization_code. + // – https://developers.google.com/identity/protocols/oauth2/native-app#exchange-authorization-code + grantType: "authorization_code", + redirectURI: URL.redirectURI(from: clientId) + ) + } +} diff --git a/WordPressAuthenticator/GoogleSignIn/URL+GoogleSignIn.swift b/WordPressAuthenticator/GoogleSignIn/URL+GoogleSignIn.swift index 628a2f558..e0e463a70 100644 --- a/WordPressAuthenticator/GoogleSignIn/URL+GoogleSignIn.swift +++ b/WordPressAuthenticator/GoogleSignIn/URL+GoogleSignIn.swift @@ -2,26 +2,46 @@ import Foundation extension URL { - // TODO: This is incomplete - static func googleSignInAuthURL(clientId: String) throws -> URL { - let baseURL = "https://accounts.google.com/o/oauth2/v2/auth" + // It's acceptable to force-unwrap here because, for this call to fail we'd need a developer + // error, which we would catch because the unit tests would crash. + static var googleSignInBaseURL = URL(string: "https://accounts.google.com/o/oauth2/v2/auth")! + static func googleSignInAuthURL(clientId: String, pkce: ProofKeyForCodeExchange) throws -> URL { let queryItems = [ ("client_id", clientId), + ("code_challenge", pkce.codeCallenge), + ("code_challenge_method", pkce.method.urlQueryParameterValue), ("redirect_uri", redirectURI(from: clientId)), - ("response_type", "code") + ("response_type", "code"), + // TODO: We might want to add some of these or them configurable + // + // The request we make with the SDK asks for: + // + // - email + // - profile + // - https://www.googleapis.com/auth/userinfo.email + // - https://www.googleapis.com/auth/userinfo.profile + // - openid + // + // See https://developers.google.com/identity/protocols/oauth2/scopes + ("scope", "https://www.googleapis.com/auth/userinfo.email") ].map { URLQueryItem(name: $0.0, value: $0.1) } if #available(iOS 16.0, *) { - return URL(string: baseURL)!.appending(queryItems: queryItems) + return googleSignInBaseURL.appending(queryItems: queryItems) } else { - var components = URLComponents(string: baseURL)! + // Given `googleSignInBaseURL` is assumed as a valid URL, a `URLComponents` instance + // should always be available. + var components = URLComponents(url: googleSignInBaseURL, resolvingAgainstBaseURL: false)! components.queryItems = queryItems - return try components.asURL() + // Likewise, we can as long as the given `queryItems` are valid, we can assume `url` to + // not be nil. If `queryItems` are invalid, a developer error has been committed, and + // crashing is appropriate. + return components.url! } } - private static func redirectURI(from clientId: String) -> String { + static func redirectURI(from clientId: String) -> String { // Google's client id is in the form: 123-abc245def.apps.googleusercontent.com // The redirect URI uses the reverse-DNS notation. let reverseDNSClientId = clientId.split(separator: ".").reversed().joined(separator: ".") diff --git a/WordPressAuthenticator/OAuth/OAuthError.swift b/WordPressAuthenticator/OAuth/OAuthError.swift index a3edf30da..3a4dc4b1e 100644 --- a/WordPressAuthenticator/OAuth/OAuthError.swift +++ b/WordPressAuthenticator/OAuth/OAuthError.swift @@ -1,5 +1,6 @@ enum OAuthError: LocalizedError { + // ASWebAuthenticationSession case inconsistentWebAuthenticationSessionCompletion var errorDescription: String { diff --git a/WordPressAuthenticator/OAuth/OAuthTokenRequestBody.swift b/WordPressAuthenticator/OAuth/OAuthTokenRequestBody.swift new file mode 100644 index 000000000..722faa419 --- /dev/null +++ b/WordPressAuthenticator/OAuth/OAuthTokenRequestBody.swift @@ -0,0 +1,39 @@ +/// Models the request to send for an OAuth token +/// +/// - Note: See documentation at https://developers.google.com/identity/protocols/oauth2/native-app#exchange-authorization-code +struct OAuthTokenRequestBody: Encodable { + let clientId: String + let clientSecret: String + let code: String + let codeVerifier: String + let grantType: String + let redirectURI: String + + enum CodingKeys: String, CodingKey { + case clientId = "client_id" + case clientSecret = "client_secret" + case code + case codeVerifier = "code_verifier" + case grantType = "grant_type" + case redirectURI = "redirect_uri" + } + + func asURLEncodedData() throws -> Data { + let params = [ + (CodingKeys.clientId.rawValue, clientId), + (CodingKeys.clientSecret.rawValue, clientSecret), + (CodingKeys.code.rawValue, code), + (CodingKeys.codeVerifier.rawValue, codeVerifier), + (CodingKeys.grantType.rawValue, grantType), + (CodingKeys.redirectURI.rawValue, redirectURI), + ] + + let items = params.map { URLQueryItem(name: $0.0, value: $0.1) } + + var components = URLComponents() + components.queryItems = items + + // We can assume `query` to never be nil because we set `queryItems` in the line above. + return Data(components.query!.utf8) + } +} diff --git a/WordPressAuthenticator/OAuth/OAuthTokenResponseBody.swift b/WordPressAuthenticator/OAuth/OAuthTokenResponseBody.swift new file mode 100644 index 000000000..4348fbb1d --- /dev/null +++ b/WordPressAuthenticator/OAuth/OAuthTokenResponseBody.swift @@ -0,0 +1,23 @@ +/// Models the response to an OAuth token request. +/// +/// - Note: See documentation at https://developers.google.com/identity/protocols/oauth2/native-app#exchange-authorization-code +struct OAuthTokenResponseBody: Decodable { + let accessToken: String + let expiresIn: Int + /// This value is only returned if the request included an identity scope, such as openid, profile, or email. + /// The value is a JSON Web Token (JWT) that contains digitally signed identity information about the user. + let idToken: String? + let refreshToken: String? + let scope: String + /// The type of token returned. At this time, this field's value is always set to Bearer. + let tokenType: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case expiresIn = "expires_in" + case idToken = "id_token" + case refreshToken = "refresh_token" + case scope + case tokenType = "token_type" + } +} diff --git a/WordPressAuthenticator/OAuth/ProofKeyForCodeExchange.swift b/WordPressAuthenticator/OAuth/ProofKeyForCodeExchange.swift new file mode 100644 index 000000000..c06f4bf13 --- /dev/null +++ b/WordPressAuthenticator/OAuth/ProofKeyForCodeExchange.swift @@ -0,0 +1,45 @@ +// See: +// - https://developers.google.com/identity/protocols/oauth2/native-app#step1-code-verifier +// - https://www.rfc-editor.org/rfc/rfc7636 +// +// FIXME: follow spec! +// +// A code_verifier is a high-entropy cryptographic random string using the unreserved +// characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~", with a minimum length of 43 +// characters and a maximum length of 128 characters. +// +// The code verifier should have enough entropy to make it impractical to guess the value. +// +// Note: The common abbreviation of "Proof Key for Code Exchange" is PKCE and is pronounced "pixy". +struct ProofKeyForCodeExchange { + + enum Method { + case s256 + case plain + + var urlQueryParameterValue: String { + switch self { + case .plain: return "plain" + case .s256: return "S256" + } + } + } + + let codeVerifier: String + let method: Method + + init(codeVerifier: String, method: Method) { + self.codeVerifier = codeVerifier + self.method = method + } + + var codeCallenge: String { + switch method { + case .s256: + // TODO: code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) + fatalError() + case .plain: + return codeVerifier + } + } +} diff --git a/WordPressAuthenticator/OAuth/URLRequest+OAuth.swift b/WordPressAuthenticator/OAuth/URLRequest+OAuth.swift new file mode 100644 index 000000000..7710612a9 --- /dev/null +++ b/WordPressAuthenticator/OAuth/URLRequest+OAuth.swift @@ -0,0 +1,11 @@ +extension URLRequest { + + static func oauthTokenRequest(baseURL: URL) throws -> URLRequest { + var request = try URLRequest(url: baseURL, method: .post) + request.setValue( + "application/x-www-form-urlencoded; charset=UTF-8", + forHTTPHeaderField: "Content-Type" + ) + return request + } +} diff --git a/WordPressAuthenticatorTests/GoogleSignIn/OAuthRequestBody+GoogleSignInTests.swift b/WordPressAuthenticatorTests/GoogleSignIn/OAuthRequestBody+GoogleSignInTests.swift new file mode 100644 index 000000000..6b9467847 --- /dev/null +++ b/WordPressAuthenticatorTests/GoogleSignIn/OAuthRequestBody+GoogleSignInTests.swift @@ -0,0 +1,20 @@ +@testable import WordPressAuthenticator +import XCTest + +class OAuthRequestBodyGoogleSignInTests: XCTestCase { + + func testGoogleSignInTokenRequestBody() throws { + let pkce = ProofKeyForCodeExchange(codeVerifier: "test", method: .plain) + let body = OAuthTokenRequestBody.googleSignInRequestBody( + clientId: "com.app.123-abc", + authCode: "codeValue", + pkce: pkce + ) + + XCTAssertEqual(body.clientId, "com.app.123-abc") + XCTAssertEqual(body.clientSecret, "") + XCTAssertEqual(body.codeVerifier, pkce.codeVerifier) + XCTAssertEqual(body.grantType, "authorization_code") + XCTAssertEqual(body.redirectURI, "123-abc.app.com:/oauth2callback") + } +} diff --git a/WordPressAuthenticatorTests/GoogleSignIn/URL+GoogleSignInTests.swift b/WordPressAuthenticatorTests/GoogleSignIn/URL+GoogleSignInTests.swift index 45d7759b5..79bf5d387 100644 --- a/WordPressAuthenticatorTests/GoogleSignIn/URL+GoogleSignInTests.swift +++ b/WordPressAuthenticatorTests/GoogleSignIn/URL+GoogleSignInTests.swift @@ -4,7 +4,11 @@ import XCTest class URLGoogleSignInTests: XCTestCase { func testGoogleSignInAuthURL() throws { - let url = try URL.googleSignInAuthURL(clientId: "123-abc245def.apps.googleusercontent.com") + let pkce = ProofKeyForCodeExchange(codeVerifier: "test", method: .plain) + let url = try URL.googleSignInAuthURL( + clientId: "123-abc245def.apps.googleusercontent.com", + pkce: pkce + ) assert(url, matchesBaseURL: "https://accounts.google.com/o/oauth2/v2/auth") assertQueryItems( @@ -12,13 +16,27 @@ class URLGoogleSignInTests: XCTestCase { includeItemNamed: "client_id", withValue: "123-abc245def.apps.googleusercontent.com" ) + assertQueryItems( + for: url, + includeItemNamed: "code_challenge", + withValue: pkce.codeCallenge + ) + assertQueryItems( + for: url, + includeItemNamed: "code_challenge_method", + withValue: pkce.method.urlQueryParameterValue + ) assertQueryItems( for: url, includeItemNamed: "redirect_uri", withValue: "com.googleusercontent.apps.123-abc245def:/oauth2callback" ) + assertQueryItems( + for: url, + includeItemNamed: "scope", + withValue: "https://www.googleapis.com/auth/userinfo.email" + ) assertQueryItems(for: url, includeItemNamed: "response_type", withValue: "code") - // TODO: need to check more parameters } } diff --git a/WordPressAuthenticatorTests/OAuth/OAuthTokenRequestBodyTests.swift b/WordPressAuthenticatorTests/OAuth/OAuthTokenRequestBodyTests.swift new file mode 100644 index 000000000..c03b29f7b --- /dev/null +++ b/WordPressAuthenticatorTests/OAuth/OAuthTokenRequestBodyTests.swift @@ -0,0 +1,26 @@ +@testable import WordPressAuthenticator +import XCTest + +class OAuthTokenRequestBodyTests: XCTestCase { + + func testURLEncodedDataConversion() throws { + let body = OAuthTokenRequestBody( + clientId: "clientId", + clientSecret: "clientSecret", + code: "codeValue", + codeVerifier: "codeVerifier", + grantType: "grantType", + redirectURI: "redirectUri" + ) + + let data = try body.asURLEncodedData() + + let decodedData = try XCTUnwrap(String(data: data, encoding: .utf8)) + + XCTAssertTrue(decodedData.contains("client_id=clientId")) + XCTAssertTrue(decodedData.contains("client_secret=clientSecret")) + XCTAssertTrue(decodedData.contains("code_verifier=codeVerifier")) + XCTAssertTrue(decodedData.contains("grant_type=grantType")) + XCTAssertTrue(decodedData.contains("redirect_uri=redirectUri")) + } +} diff --git a/WordPressAuthenticatorTests/OAuth/ProofKeyForCodeExchangeTests.swift b/WordPressAuthenticatorTests/OAuth/ProofKeyForCodeExchangeTests.swift new file mode 100644 index 000000000..a546669ce --- /dev/null +++ b/WordPressAuthenticatorTests/OAuth/ProofKeyForCodeExchangeTests.swift @@ -0,0 +1,24 @@ +@testable import WordPressAuthenticator +import XCTest + +class ProofKeyForCodeExchangeTests: XCTestCase { + + func testCodeChallengeInPlainModeIsTheSameAsCodeVerifier() { + XCTAssertEqual( + ProofKeyForCodeExchange(codeVerifier: "abc", method: .plain).codeCallenge, + "abc" + ) + } + + func testCodeChallengeInS256ModeIsEncodedAsPerSpec() { + // TODO: + } + + func testMethodURLQueryParameterValuePlain() { + XCTAssertEqual(ProofKeyForCodeExchange.Method.plain.urlQueryParameterValue, "plain") + } + + func testMethodURLQueryParameterValueS256() { + XCTAssertEqual(ProofKeyForCodeExchange.Method.s256.urlQueryParameterValue, "S256") + } +} diff --git a/WordPressAuthenticatorTests/OAuth/URLRequest+OAuthTests.swift b/WordPressAuthenticatorTests/OAuth/URLRequest+OAuthTests.swift new file mode 100644 index 000000000..2ecc3590c --- /dev/null +++ b/WordPressAuthenticatorTests/OAuth/URLRequest+OAuthTests.swift @@ -0,0 +1,25 @@ +@testable import WordPressAuthenticator +import XCTest + +class URLRequestOAuthTokenRequestTests: XCTestCase { + + let testURL = URL(string: "https://test.com")! + + func testUsesGivenBaseURL() throws { + let request = try URLRequest.oauthTokenRequest(baseURL: testURL) + XCTAssertEqual(request.url, testURL) + } + + func testMethodPost() throws { + let request = try URLRequest.oauthTokenRequest(baseURL: testURL) + XCTAssertEqual(request.httpMethod, "POST") + } + + func testContentTypeFormURLEncoded() throws { + let request = try URLRequest.oauthTokenRequest(baseURL: testURL) + XCTAssertEqual( + request.value(forHTTPHeaderField: "Content-Type"), + "application/x-www-form-urlencoded; charset=UTF-8" + ) + } +}