Skip to content

Commit

Permalink
update OAuth
Browse files Browse the repository at this point in the history
  • Loading branch information
jdmcd committed May 25, 2019
1 parent a47fa72 commit 99cec5d
Show file tree
Hide file tree
Showing 3 changed files with 195 additions and 67 deletions.
203 changes: 167 additions & 36 deletions Sources/OneRoster/Client/OAuth.swift
Original file line number Diff line number Diff line change
@@ -1,29 +1,183 @@
// Parts of this file are taken from OhhAuth. License included below:
//
// OneRosterAPI.swift
// OneRoster
//
// Copyright Slate Solutions, Inc 2019.
//
// Apache License, Version 2.0
//
// Copyright 2017, Markus Wanke
//
// 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.
//

/// # OhhAuth
/// ## Pure Swift implementation of the OAuth 1.0 protocol as an easy to use extension for the URLRequest type.
/// - Author: Markus Wanke
/// - Copyright: 2017

import Foundation
import Crypto

struct OAuth {
let clientId: String
let clientSecret: String
let baseUrl: String
let endpoint: OneRosterAPI.Endpoint
let limit: Int?
let offset: Int?
let consumerKey: String
let consumerSecret: String
let url: URL
let userKey: String?
let userSecret: String?

init(consumerKey: String, consumerSecret: String, url: URL, userKey: String? = nil, userSecret: String? = nil) {
self.consumerKey = consumerKey
self.consumerSecret = consumerSecret
self.url = url
self.userKey = userKey
self.userSecret = userSecret
}

func generate(nonce: String? = nil, timestamp: Double? = nil, method: String = "GET") throws -> OAuthData {

typealias Tup = (key: String, value: String)

let tuplify: (String, String) -> Tup = {
return (key: self.rfc3986encode($0), value: self.rfc3986encode($1))
}

let cmp: (Tup, Tup) -> Bool = {
return $0.key < $1.key
}

let toPairString: (Tup) -> String = {
return $0.key + "=" + $0.value
}

let toBrackyPairString: (Tup) -> String = {
return $0.key + "=\"" + $0.value + "\""
}

/// [RFC-5849 Section 3.1](https://tools.ietf.org/html/rfc5849#section-3.1)
let passedNonce = nonce ?? UUID().uuidString
let signatureMethod = "HMAC-SHA256"
let timestampString = timestamp == nil ? String(Int(Date().timeIntervalSince1970)) : String(timestamp!)
var oAuthParameters = oAuthDefaultParameters(consumerKey: consumerKey,
signatureMethod: signatureMethod,
timestamp: timestampString,
userKey: userKey,
nonce: passedNonce)

/// [RFC-5849 Section 3.4.1.3.1](https://tools.ietf.org/html/rfc5849#section-3.4.1.3.1)
let signString: String = [oAuthParameters, url.queryParameters()]
.flatMap { $0.map(tuplify) }
.sorted(by: cmp)
.map(toPairString)
.joined(separator: "&")

/// [RFC-5849 Section 3.4.1](https://tools.ietf.org/html/rfc5849#section-3.4.1)
let signatureBase: String = [method, url.oAuthBaseURL(), signString]
.map(rfc3986encode)
.joined(separator: "&")

/// [RFC-5849 Section 3.4.2](https://tools.ietf.org/html/rfc5849#section-3.4.2)
let signingKey: String = [consumerSecret, userSecret ?? ""]
.map(rfc3986encode)
.joined(separator: "&")

/// [RFC-5849 Section 3.4.2](https://tools.ietf.org/html/rfc5849#section-3.4.2)
let binarySignature = try HMAC.SHA256.authenticate(signatureBase, key: signingKey)
oAuthParameters["oauth_signature"] = binarySignature.base64EncodedString()

let signatureHeader = "OAuth " + oAuthParameters
.map(tuplify)
.sorted(by: cmp)
.map(toBrackyPairString)
.joined(separator: ",")

return OAuthData(oauthHeaderString: signatureHeader, signature: binarySignature.base64EncodedString())
}

private func rfc3986encode(_ str: String) -> String {
let allowed = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-._~"
return str.addingPercentEncoding(withAllowedCharacters: CharacterSet(charactersIn: allowed)) ?? str
}

private func oAuthDefaultParameters(consumerKey: String,
signatureMethod: String,
timestamp: String,
userKey: String? = nil,
nonce: String? = nil) -> [String: String]
{
/// [RFC-5849 Section 3.1](https://tools.ietf.org/html/rfc5849#section-3.1)
var defaults: [String: String] = [
"oauth_consumer_key": consumerKey,
"oauth_signature_method": signatureMethod,
"oauth_version": "1.0",
"oauth_timestamp": timestamp,
"oauth_nonce": nonce ?? UUID().uuidString,
]

if let userKey = userKey {
defaults["oauth_token"] = userKey
}

return defaults
}
}

struct OAuthData {
let oauthHeaderString: String
let signature: String
}

extension URL {
/// Transforms: "www.x.com?color=red&age=29" to ["color": "red", "age": "29"]
func queryParameters() -> [String: String] {
var res: [String: String] = [:]
for qi in URLComponents(url: self, resolvingAgainstBaseURL: true)?.queryItems ?? [] {
res[qi.name] = qi.value ?? ""
}
return res
}

func generate(nonce: String? = nil, timestamp: Double? = nil) throws -> OAuthData {
func oAuthBaseURL() -> String {
let scheme = self.scheme?.lowercased() ?? ""
let host = self.host?.lowercased() ?? ""

var authority = ""
if let user = self.user, let pw = self.password {
authority = user + ":" + pw + "@"
}
else if let user = self.user {
authority = user + "@"
}

var port = ""
if let iport = self.port, iport != 80, scheme == "http" {
port = ":\(iport)"
}
else if let iport = self.port, iport != 443, scheme == "https" {
port = ":\(iport)"
}

return scheme + "://" + authority + host + port + self.path
}
}

extension OneRosterAPI.Endpoint {
func fullUrl(baseUrl: String, limit: Int? = nil, offset: Int? = nil) -> URL? {
let url: String
let parametersString: String?

if baseUrl.hasSuffix("/") {
url = "\(baseUrl)\(endpoint.endpoint)"
url = "\(baseUrl)\(self.endpoint)"
} else {
url = "\(baseUrl)/\(endpoint.endpoint)"
url = "\(baseUrl)/\(self.endpoint)"
}

if let limit = limit, let offset = offset {
Expand All @@ -36,29 +190,6 @@ struct OAuth {
parametersString = nil
}

let fullUrl = "\(url)\(parametersString ?? "")"

// Create the OAuth 1.0 signature
let httpVerb = "GET"
let oauthConsumerKey = clientId
let oauthNonce = nonce ?? UUID().uuidString
let oauthSignatureMethod = "HMAC-SHA256"
let oauthTimestamp = timestamp ?? Date().timeIntervalSince1970
let oauthVersion = "1.0"

let oauthSignatureComponent = "\(limit == nil ? "" : "limit=\(limit!)&")oauth_consumer_key=\(oauthConsumerKey)&oauth_nonce=\(oauthNonce)&oauth_signature_method=\(oauthSignatureMethod)&oauth_timestamp=\(oauthTimestamp)&oauth_version=\(oauthVersion)\(offset == nil ? "" : "&offset=\(offset!)")"
let oauthUnecryptedSignature = "\(httpVerb)&\(url.cleanUrl)&\(oauthSignatureComponent.cleanUrl)"

let oauthSignature = try HMAC.SHA256.authenticate(oauthUnecryptedSignature.convertToData(), key: "\(clientSecret)&".convertToData()).base64EncodedString().cleanUrl

let oauthHeaderString = #"OAuth oauth_consumer_key="\#(oauthConsumerKey)", oauth_nonce="\#(oauthNonce)", oauth_signature="\#(oauthSignature)", oauth_signature_method="\#(oauthSignatureMethod)", oauth_timestamp="\#(oauthTimestamp)", oauth_version="\#(oauthVersion)""#

return OAuthData(oauthHeaderString: oauthHeaderString, signature: oauthSignature, fullUrl: fullUrl)
return URL(string: "\(url)\(parametersString ?? "")")
}
}

struct OAuthData {
let oauthHeaderString: String
let signature: String
let fullUrl: String
}
38 changes: 16 additions & 22 deletions Sources/OneRoster/Client/OneRosterClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,20 @@ public struct OneRosterClient: Service {
bypassRecursion: Bool = false) throws -> Future<[C.InnerType]>
{

let oauthData = try OAuth(clientId: clientId,
clientSecret: clientSecret,
baseUrl: baseUrl,
endpoint: endpoint,
limit: limit,
offset: offset).generate()
guard let url = endpoint.fullUrl(baseUrl: baseUrl, limit: limit, offset: offset) else {
return client.container.future(error: Abort(.internalServerError, reason: "Cannot generate URL"))
}

let oauthData = try OAuth(consumerKey: clientId,
consumerSecret: clientSecret,
url: url).generate()

let headers: HTTPHeaders = [
"Authorization": oauthData.oauthHeaderString
]

let jsonDecoder = decoder()
return client.get(oauthData.fullUrl, headers: headers).flatMap { res in
return client.get(url.absoluteString, headers: headers).flatMap { res in
guard let data = res.http.body.data else { throw Abort(.internalServerError) }
let totalEntityCount = Int(res.http.headers["x-total-count"].first ?? "") ?? 1
let pageCountDouble = Double(totalEntityCount) / Double(limit)
Expand Down Expand Up @@ -92,29 +93,22 @@ public struct OneRosterClient: Service {
clientSecret: String,
endpoint: OneRosterAPI.Endpoint) throws -> Future<C>
{
let oauthData = try OAuth(clientId: clientId,
clientSecret: clientSecret,
baseUrl: baseUrl,
endpoint: endpoint,
limit: nil,
offset: nil).generate()
guard let url = endpoint.fullUrl(baseUrl: baseUrl) else {
return client.container.future(error: Abort(.internalServerError, reason: "Cannot generate URL"))
}

let oauthData = try OAuth(consumerKey: clientId,
consumerSecret: clientSecret,
url: url).generate()

let headers: HTTPHeaders = [
"Authorization": oauthData.oauthHeaderString
]

let jsonDecoder = decoder()
return client.get(oauthData.fullUrl, headers: headers).map { res in
return client.get(url.absoluteString, headers: headers).map { res in
guard let data = res.http.body.data else { throw Abort(.internalServerError) }
return try jsonDecoder.decode(C.self, from: data)
}
}
}

extension String {
var cleanUrl: String {
return self.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)?
.replacingOccurrences(of: "&", with: "%26")
.replacingOccurrences(of: "=", with: "%3D") ?? ""
}
}
21 changes: 12 additions & 9 deletions Tests/OneRosterTests/OneRosterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,22 @@ import XCTest

final class OneRosterTests: XCTestCase {
func testOauth() throws {
let oauthData = try OAuth(clientId: "client-id",
clientSecret: "client-secret",
baseUrl: "https://test.com/ims/oneroster/v1p1/",
endpoint: .getAllOrgs,
limit: 100,
offset: 1515).generate(nonce: "fake-nonce", timestamp: 10000000)
guard let url = OneRosterAPI.Endpoint.getAllOrgs.fullUrl(baseUrl: "https://test.com/ims/oneroster/v1p1/", limit: 100, offset: 1515) else {
XCTFail("Could not generate URL")
return
}

let expectedSignature = "ONJ%2FjQwnt6+dosTnICfxqbE6F2945oZz0sDJipo64ZY%3D"
let oauthData = try OAuth(consumerKey: "client-id",
consumerSecret: "client-secret",
url: url).generate(nonce: "fake-nonce", timestamp: 10000000)

let expectedSignature = "ONJ/jQwnt6+dosTnICfxqbE6F2945oZz0sDJipo64ZY="
let expectedSignatureEncoded = "ONJ%2FjQwnt6%2BdosTnICfxqbE6F2945oZz0sDJipo64ZY%3D"
let expectedUrl = "https://test.com/ims/oneroster/v1p1/orgs?limit=100&offset=1515"
let expectedHeaderString = "OAuth oauth_consumer_key=\"client-id\", oauth_nonce=\"fake-nonce\", oauth_signature=\"ONJ%2FjQwnt6+dosTnICfxqbE6F2945oZz0sDJipo64ZY%3D\", oauth_signature_method=\"HMAC-SHA256\", oauth_timestamp=\"10000000.0\", oauth_version=\"1.0\""
let expectedHeaderString = "OAuth oauth_consumer_key=\"client-id\",oauth_nonce=\"fake-nonce\",oauth_signature=\"\(expectedSignatureEncoded)\",oauth_signature_method=\"HMAC-SHA256\",oauth_timestamp=\"10000000.0\",oauth_version=\"1.0\""

XCTAssertEqual(oauthData.signature, expectedSignature)
XCTAssertEqual(oauthData.fullUrl, expectedUrl)
XCTAssertEqual(url.absoluteString, expectedUrl)
XCTAssertEqual(oauthData.oauthHeaderString, expectedHeaderString)
}

Expand Down

0 comments on commit 99cec5d

Please sign in to comment.