Skip to content

Commit

Permalink
#3 #4 #5 Finish up entire Authentication Workflow, however ASWebAuthe…
Browse files Browse the repository at this point in the history
…nticationSession is commented out before it breaks testing until I can figure out how to conditionally import it only on the proper OSs
  • Loading branch information
Edward Hinkle committed Jun 11, 2019
1 parent ba88760 commit 3c0f424
Show file tree
Hide file tree
Showing 3 changed files with 231 additions and 37 deletions.
139 changes: 103 additions & 36 deletions Sources/IndieWebKit/IndieAuth/AuthenticationRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import Foundation
import CryptoSwift
import AuthenticationServices
//import AuthenticationServices

public class AuthenticationRequest {

Expand All @@ -19,18 +19,23 @@ public class AuthenticationRequest {
private var codeChallenge: String?
private let codeChallengeMethod = "S256"

private var url: URL? {
var url: URL? {
var requestUrl = URLComponents(url: authorizationEndpoint, resolvingAgainstBaseURL: false)
requestUrl?.queryItems?.append(URLQueryItem(name: "me", value: profile.absoluteString))
requestUrl?.queryItems?.append(URLQueryItem(name: "client_id", value: clientId.absoluteString))
requestUrl?.queryItems?.append(URLQueryItem(name: "redirect_uri", value: redirectUri.absoluteString))
requestUrl?.queryItems?.append(URLQueryItem(name: "state", value: state))
var queryItems: [URLQueryItem] = []

queryItems.append(URLQueryItem(name: "me", value: profile.absoluteString))
queryItems.append(URLQueryItem(name: "client_id", value: clientId.absoluteString))
queryItems.append(URLQueryItem(name: "redirect_uri", value: redirectUri.absoluteString))
queryItems.append(URLQueryItem(name: "state", value: state))
queryItems.append(URLQueryItem(name: "response_type", value: "id"))

if codeChallenge != nil {
requestUrl?.queryItems?.append(URLQueryItem(name: "code_challenge", value: codeChallenge))
requestUrl?.queryItems?.append(URLQueryItem(name: "code_challenge_method", value: codeChallengeMethod))
queryItems.append(URLQueryItem(name: "code_challenge_method", value: codeChallengeMethod))
queryItems.append(URLQueryItem(name: "code_challenge", value: codeChallenge))
}

requestUrl?.queryItems = queryItems

return requestUrl?.url
}

Expand All @@ -47,34 +52,41 @@ public class AuthenticationRequest {
}
}

func start(completion: @escaping ((String?) -> ())) {
@available(iOS 12.0, macOS 10.15, *)
func start(completion: @escaping ((URL?) -> ())) {
guard url != nil else {
// TODO: Throw some type of error
return
}

ASWebAuthenticationSession(url: url!, callbackURLScheme: nil) { [weak self] responseUrl, error in
guard error == nil else {
// TODO: Throw some type of error
return
}

guard responseUrl != nil else {
// TODO: Throw some type of error
return
}

let authorizationCode = self?.parseResponse(responseUrl!)
guard authorizationCode != nil else {
// TODO: Throw an error because authorization code should not be nil
return
}

verifyAuthenticationCode(authorizationCode!)
}.start()
// ASWebAuthenticationSession(url: url!, callbackURLScheme: nil) { [weak self] responseUrl, error in
// guard error == nil else {
// // TODO: Throw some type of error
// return
// }
//
// guard responseUrl != nil else {
// // TODO: Throw some type of error
// return
// }
//
// let authorizationCode = self?.parseResponse(responseUrl!)
// guard authorizationCode != nil else {
// // TODO: Throw an error because authorization code should not be nil
// return
// }
//
// self?.verifyAuthenticationCode(authorizationCode!) { [weak self] codeVerified in
// if (codeVerified) {
// completion(self?.profile)
// } else {
// completion(nil)
// }
// }
// }.start()
}

private func parseResponse(_ responseUrl: URL) -> String {
func parseResponse(_ responseUrl: URL) -> String {
let responseComponents = URLComponents(url: responseUrl, resolvingAgainstBaseURL: false)
var state = ""
var code = ""
Expand All @@ -95,16 +107,71 @@ public class AuthenticationRequest {
return code
}

private func verifyAuthenticationCode(_ code: String) {
// TODO: Resume #5 here
func verifyAuthenticationCode(_ code: String, completion: @escaping ((Bool) -> Void)) {

do {
let verificationRequest = try getVerificationRequest(with: code)

URLSession.shared.dataTask(with: verificationRequest) { [weak self] body, response, error in
guard error == nil else {
// TODO: Throw error here
return
}

// TODO: Check to make sure content type is application/json

guard body != nil else {
// TODO: throw error here
return
}

let responseProfile = try! JSONDecoder().decode([String:URL].self, from: body!)

completion(self!.confirmVerificationResponse(responseProfile))

}.resume()

} catch {
// TODO: Figure out how to report error
completion(false)
}
}

func getVerificationRequest(with code: String) throws -> URLRequest {
var request = URLRequest(url: authorizationEndpoint)
request.httpMethod = "POST"
request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.addValue("application/json", forHTTPHeaderField: "Accept")

let postBody = [
"code": code,
"client_id": clientId.absoluteString,
"redirect_uri": redirectUri.absoluteString
]

try request.httpBody = JSONEncoder().encode(postBody)

return request
}

private func generateDefaultCodeChallenge() -> String? {
return Data(base64Encoded: randomString(length: 128).sha256())?.base64EncodedString()
func confirmVerificationResponse(_ responseProfile: [String:URL]) -> Bool {
guard responseProfile["me"] != nil else {
return false
}

let meComponents = URLComponents(url: responseProfile["me"]!, resolvingAgainstBaseURL: false)
let profileComponents = URLComponents(url: profile, resolvingAgainstBaseURL: false)

let validProfile = meComponents?.host == profileComponents?.host

if (validProfile) {
profile = responseProfile["me"]!
}

return validProfile
}

private func randomString(length: Int) -> String {
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~"
return String((0..<length).map{ _ in letters.randomElement()! })
private func generateDefaultCodeChallenge() -> String? {
return Data(base64Encoded: String.randomString(length: 128).sha256())?.base64EncodedString()
}
}
24 changes: 24 additions & 0 deletions Sources/IndieWebKit/String.extension.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// File.swift
//
//
// Created by ehinkle-ad on 6/11/19.
//

import Foundation
extension String {
public static func randomString(length: Int) -> String {
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~"
return randomString(length: length, from: letters)
}

public static func randomAlphaNumericString(length: Int) -> String {
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return randomString(length: length, from: letters)
}

public static func randomString(length: Int, from stringOptions: String) -> String {
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~"
return String((0..<length).map{ _ in letters.randomElement()! })
}
}
105 changes: 104 additions & 1 deletion Tests/IndieWebKitTests/IndieAuthTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,111 @@ final class IndieAuthTests: XCTestCase {

}

func testAuthenticationRequest() {
// IndieAuth Spec 5.2 Building Authentication Request URL
// https://indieauth.spec.indieweb.org/#authentication-request
func testAuthenticationRequestUrl() {

let profile = URL(string: "https://eddiehinkle.com")!
let authorization_endpoint = URL(string: "https://eddiehinkle.com/auth")!
let client_id = URL(string: "https://remark.social")!
let redirect_uri = URL(string: "https://remark.social/ios/callback")!
let state = String.randomAlphaNumericString(length: 25)

let request = AuthenticationRequest(for: profile,
at: authorization_endpoint,
clientId: client_id,
redirectUri: redirect_uri,
state: state,
codeChallenge: nil)

XCTAssertTrue(request.url!.absoluteString.contains("\(authorization_endpoint)?me=\(profile)&client_id=\(client_id)&redirect_uri=\(redirect_uri)&state=\(state)&response_type=id&code_challenge_method=S256&code_challenge="))
}

// IndieAuth Spec 5.3 Parsing the Authentication Response
// https://indieauth.spec.indieweb.org/#authentication-response
func testParseAuthenticationResponse() {
let profile = URL(string: "https://eddiehinkle.com")!
let authorization_endpoint = URL(string: "https://eddiehinkle.com/auth")!
let client_id = URL(string: "https://remark.social")!
let redirect_uri = URL(string: "https://remark.social/ios/callback")!
let state = String.randomAlphaNumericString(length: 25)

let request = AuthenticationRequest(for: profile,
at: authorization_endpoint,
clientId: client_id,
redirectUri: redirect_uri,
state: state,
codeChallenge: nil)

let authorization_code_from_server = String.randomAlphaNumericString(length: 20)

let parsed_authorization_code = request.parseResponse(URL(string: "\(redirect_uri)?code=\(authorization_code_from_server)&state=\(state)")!)
XCTAssertEqual(parsed_authorization_code, authorization_code_from_server)
}

// IndieAuth Spec 5.4 Authorization Code Verification Request
// https://indieauth.spec.indieweb.org/#authorization-code-verification
func testAuthorizationCodeVerificationRequest() {

let profile = URL(string: "https://eddiehinkle.com")!
let authorization_endpoint = URL(string: "https://eddiehinkle.com/auth")!
let client_id = URL(string: "https://remark.social")!
let redirect_uri = URL(string: "https://remark.social/ios/callback")!
let state = String.randomAlphaNumericString(length: 25)

let request = AuthenticationRequest(for: profile,
at: authorization_endpoint,
clientId: client_id,
redirectUri: redirect_uri,
state: state,
codeChallenge: nil)

let authorization_code = String.randomAlphaNumericString(length: 20)

let verificationRequest: URLRequest = try! request.getVerificationRequest(with: authorization_code)

XCTAssertEqual(verificationRequest.httpMethod, "POST")
XCTAssertEqual(verificationRequest.url, authorization_endpoint)

let bodyDictionary = try! JSONDecoder().decode([String:String].self, from: verificationRequest.httpBody!)

XCTAssertEqual(bodyDictionary["code"], authorization_code)
XCTAssertEqual(bodyDictionary["client_id"], client_id.absoluteString)
XCTAssertEqual(bodyDictionary["redirect_uri"], redirect_uri.absoluteString)
}

// IndieAuth Spec 5.4 Authorization Code Verification Response
// https://indieauth.spec.indieweb.org/#authorization-code-verification
func testAuthorizationCodeVerificationResponse() {

let profile = URL(string: "https://eddiehinkle.com")!
let authorization_endpoint = URL(string: "https://eddiehinkle.com/auth")!
let client_id = URL(string: "https://remark.social")!
let redirect_uri = URL(string: "https://remark.social/ios/callback")!
let state = String.randomAlphaNumericString(length: 25)

let request = AuthenticationRequest(for: profile,
at: authorization_endpoint,
clientId: client_id,
redirectUri: redirect_uri,
state: state,
codeChallenge: nil)

let sameProfile = profile
let responseWithSameProfile = [ "me": sameProfile ]
let isValidMe = request.confirmVerificationResponse(responseWithSameProfile)
XCTAssertTrue(isValidMe)

var subProfile = URLComponents(url: profile, resolvingAgainstBaseURL: false)!
subProfile.path = "/path/under"
let responseWithSubProfile = [ "me": subProfile.url! ]
let isValidMe2 = request.confirmVerificationResponse(responseWithSubProfile)
XCTAssertTrue(isValidMe2)

let spoofedProfile = URL(string: "https://spoofing.com")!
let responseWithSpoofedProfile = [ "me": spoofedProfile ]
let isValidMe3 = request.confirmVerificationResponse(responseWithSpoofedProfile)
XCTAssertFalse(isValidMe3)
}

// TODO: Write a test that returns several of the same endpoint and make sure that the FIRST endpoint is used
Expand Down

0 comments on commit 3c0f424

Please sign in to comment.