Skip to content

Commit

Permalink
Authenticate tokens without X-Token-Type (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
djones6 authored Sep 26, 2019
1 parent 7b77c54 commit 87dc061
Show file tree
Hide file tree
Showing 31 changed files with 520 additions and 170 deletions.
34 changes: 26 additions & 8 deletions Sources/CredentialsJWT/CredentialsJWT.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ import LoggerAPI
A plugin for Kitura-Credentials supporting authentication using [JSON Web Tokens](https://jwt.io/).

This plugin requires that the following HTTP headers are present on a request:
- `X-token-type`: must be `JWT`
- `Authorization`: the JWT string, optionally prefixed with `Bearer`.
- `Authorization`: the JWT string, optionally prefixed with `Bearer`

If you wish to use multiple Credentials plugins, then additionally the header:
- `X-token-type`: must equal `JWT`.

The [Swift-JWT](https://github.com/IBM-Swift/Swift-JWT) library is used to
decode JWT strings. To successfully decode it, you must specify the `Claims` that will
Expand Down Expand Up @@ -169,6 +171,10 @@ public class CredentialsJWT<C: Claims>: CredentialsPluginProtocol {

/// Authenticate incoming request using a JWT.
///
/// Behaviour depends on the presence (and value) of the `X-token-type` header:
/// - `X-token-type: JWT`: Expects a valid JWT string in the `Authorization` header.
/// - no `X-token-type` header: Attempts to extract a valid JWT string from the `Authorization` header, but will defer to other plugins (rather than failing authentication).
///
/// - Parameter request: The `RouterRequest` object used to get information
/// about the request.
/// - Parameter response: The `RouterResponse` object used to respond to the
Expand All @@ -185,8 +191,9 @@ public class CredentialsJWT<C: Claims>: CredentialsPluginProtocol {
onFailure: @escaping (HTTPStatusCode?, [String:String]?) -> Void,
onPass: @escaping (HTTPStatusCode?, [String:String]?) -> Void,
inProgress: @escaping () -> Void) {

if let type = request.headers["X-token-type"], type == name {

let noTokenType = (request.headers["X-token-type"] == nil)
if noTokenType || request.headers["X-token-type"] == .some(self.name) {
if let rawToken = request.headers["Authorization"] {
if rawToken.hasPrefix("Bearer") {
let rawTokenParts = rawToken.split(separator: " ", maxSplits: 2)
Expand Down Expand Up @@ -243,14 +250,25 @@ public class CredentialsJWT<C: Claims>: CredentialsPluginProtocol {
self.usersCache?.setObject(newCacheElement, forKey: key)
onSuccess(userProfile)
} catch {
Log.info("JWT can't be verified: \(error)")
onFailure(nil, nil)
// Authorization header did not contain a valid JWT
if (noTokenType) {
// No X-token-type header: Allow other plugins to attempt to authenticate the Authorization header
onPass(nil, nil)
} else {
Log.info("JWT can't be verified: \(error)")
onFailure(nil, nil)
}
}

} else {
// No Authorization header
Log.debug("Missing authorization header")
onFailure(nil, nil)
if (noTokenType) {
// No X-token-type header: Allow other plugins to authenticate
onPass(nil, nil)
} else {
Log.debug("Missing authorization header")
onFailure(nil, nil)
}
}

} else {
Expand Down
18 changes: 13 additions & 5 deletions Sources/CredentialsJWT/TypeSafeJWT.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,22 +102,30 @@ extension JWT: TypeSafeCredentials {
}
}

/// Attempts to authenticate a request with an Authorization header containing a JWT.
/// The possible outcomes depend on the `X-token-type` header:
/// - If `X-token-type` is set to `JWT`, then this will either succeed or fail.
/// - If `X-token-type` is set to another value, this will pass (defer to other type-safe
/// middlewares in the case of multi-auth).
/// - If `X-token-type` is not set, then this and will either succeed or pass.
public static func authenticate(request: RouterRequest, response: RouterResponse,
onSuccess: @escaping (JWT<T>) -> Void,
onFailure: @escaping (HTTPStatusCode?, [String : String]?) -> Void,
onSkip: @escaping (HTTPStatusCode?, [String : String]?) -> Void) {
// Check whether this request declares that a Google token is being supplied
guard let type = request.headers["X-token-type"], type == "JWT" else {
// Check whether this request declares that a JWT is being supplied.
let tokenHeader = request.headers["X-token-type"]
let noTokenType = (tokenHeader == nil)
if let tokenHeader = tokenHeader, tokenHeader != "JWT" {
return onSkip(nil, nil)
}
// Check whether a token has been supplied
guard let authHeader = request.headers["Authorization"] else {
return onFailure(nil, nil)
return noTokenType ? onSkip(nil, nil) : onFailure(nil, nil)
}
// Unpack the token from the header
let authParts = authHeader.split(separator: " ", maxSplits: 2)
guard authParts.count == 2, authParts[0] == "Bearer" else {
return onFailure(nil, nil)
return noTokenType ? onSkip(nil, nil) : onFailure(nil, nil)
}
let token = String(authParts[1])

Expand All @@ -129,7 +137,7 @@ extension JWT: TypeSafeCredentials {
guard let verifier = TypeSafeJWT.verifier,
let jwt = try? JWT<T>(jwtString: token, verifier: verifier)
else {
return onFailure(nil, nil)
return noTokenType ? onSkip(nil, nil) : onFailure(nil, nil)
}
saveInCache(profile: jwt, token: token)
onSuccess(jwt)
Expand Down
14 changes: 14 additions & 0 deletions Tests/CredentialsJWTTests/Middlewares/MyDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Credentials

// A UserProfileDelegate for the route accessed in testDelegateToken. Custom claims
// 'fullName' and 'email' are applied to the UserProfile 'displayName' and 'emails'
// fields.
struct MyDelegate: UserProfileDelegate {
func update(userProfile: UserProfile, from dictionary: [String:Any]) {
// `userProfile.id` already contains `id`
userProfile.displayName = dictionary["fullName"]! as! String
let email = UserProfile.UserProfileEmail(value: dictionary["email"]! as! String, type: "home")
userProfile.emails = [email]
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,20 @@ import LoggerAPI
import Credentials
import Foundation

// CLASS TAKEN FROM CREDENTIALS GOOGLE AND USED ONLY IN TESTS.
public class CredentialsGoogleToken: CredentialsPluginProtocol {
// Simplified copy of CredentialsGoogleToken, only used in tests.
class TestCredentialsGoogleToken: CredentialsPluginProtocol {

public var usersCache: NSCache<NSString, BaseCacheElement>?
var usersCache: NSCache<NSString, BaseCacheElement>?

public var name: String {
return "GoogleToken"
}
var name: String { return "GoogleToken" }

public var redirecting: Bool {
return false
}
public func authenticate(request: RouterRequest, response: RouterResponse,
options: [String:Any], onSuccess: @escaping (UserProfile) -> Void,
onFailure: @escaping (HTTPStatusCode?, [String:String]?) -> Void,
onPass: @escaping (HTTPStatusCode?, [String:String]?) -> Void,
inProgress: @escaping () -> Void) {
var redirecting: Bool { return false }

func authenticate(request: RouterRequest, response: RouterResponse,
options: [String:Any], onSuccess: @escaping (UserProfile) -> Void,
onFailure: @escaping (HTTPStatusCode?, [String:String]?) -> Void,
onPass: @escaping (HTTPStatusCode?, [String:String]?) -> Void,
inProgress: @escaping () -> Void) {
if let type = request.headers["X-token-type"], type == name {
if request.headers["access_token"] != nil {
let googleProfile = UserProfile(id: "TestGoogle", displayName: "TestGoogle", provider: "GoogleToken")
Expand Down
146 changes: 146 additions & 0 deletions Tests/CredentialsJWTTests/Middlewares/TestHTTPBasic.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Copyright IBM Corporation 2016-2019
*
* 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 Kitura
import KituraNet
import Credentials

import Foundation

// Simplified copy of CredentialsHTTP, only used in tests.
class TestCredentialsHTTPBasic : CredentialsPluginProtocol {

var name: String { return "HTTPBasic" }

var redirecting: Bool { return false }

var usersCache: NSCache<NSString, BaseCacheElement>?

typealias VerifyPassword = (String, String, @escaping (UserProfile?) -> Void) -> Void
private var verifyPassword: VerifyPassword

init (verifyPassword: @escaping VerifyPassword, realm: String?=nil) {
self.verifyPassword = verifyPassword
}

func authenticate (request: RouterRequest, response: RouterResponse,
options: [String:Any], onSuccess: @escaping (UserProfile) -> Void,
onFailure: @escaping (HTTPStatusCode?, [String:String]?) -> Void,
onPass: @escaping (HTTPStatusCode?, [String:String]?) -> Void,
inProgress: @escaping () -> Void) {

var authorization : String
if let user = request.urlURL.user, let password = request.urlURL.password {
authorization = user + ":" + password
}
else {
let options = Data.Base64DecodingOptions(rawValue: 0)

guard let authorizationHeader = request.headers["Authorization"] else {
onPass(.unauthorized, ["WWW-Authenticate" : "Basic realm=\"Users\""])
return
}

let authorizationHeaderComponents = authorizationHeader.components(separatedBy: " ")
guard authorizationHeaderComponents.count == 2,
authorizationHeaderComponents[0] == "Basic",
let decodedData = Data(base64Encoded: authorizationHeaderComponents[1], options: options),
let userAuthorization = String(data: decodedData, encoding: .utf8) else {
onPass(.unauthorized, ["WWW-Authenticate" : "Basic realm=\"Users\""])
return
}

authorization = userAuthorization as String
}

let credentials = authorization.split(separator: ":", maxSplits: 1)
guard credentials.count == 2 else {
onFailure(.badRequest, nil)
return
}

let userid = String(credentials[0])
let password = String(credentials[1])

verifyPassword(userid, password) { userProfile in
if let userProfile = userProfile {
onSuccess(userProfile)
}
else {
onFailure(.unauthorized, ["WWW-Authenticate" : "Basic realm=\"Users\""])
}
}
}
}

protocol TypeSafeHTTPBasic : TypeSafeCredentials {
static var realm: String { get }
static func verifyPassword(username: String, password: String, callback: @escaping (Self?) -> Void) -> Void
}

extension TypeSafeHTTPBasic {
public var provider: String { return "HTTPBasic" }
public static var realm: String { return "User" }

public static func authenticate(request: RouterRequest, response: RouterResponse, onSuccess: @escaping (Self) -> Void, onFailure: @escaping (HTTPStatusCode?, [String : String]?) -> Void, onSkip: @escaping (HTTPStatusCode?, [String : String]?) -> Void) {

let userid: String
let password: String
if let requestUser = request.urlURL.user, let requestPassword = request.urlURL.password {
userid = requestUser
password = requestPassword
} else {
guard let authorizationHeader = request.headers["Authorization"] else {
return onSkip(.unauthorized, ["WWW-Authenticate" : "Basic realm=\"" + realm + "\""])
}

let authorizationHeaderComponents = authorizationHeader.components(separatedBy: " ")
guard authorizationHeaderComponents.count == 2,
authorizationHeaderComponents[0] == "Basic",
let decodedData = Data(base64Encoded: authorizationHeaderComponents[1], options: Data.Base64DecodingOptions(rawValue: 0)),
let userAuthorization = String(data: decodedData, encoding: .utf8) else {
return onSkip(.unauthorized, ["WWW-Authenticate" : "Basic realm=\"" + realm + "\""])
}
let credentials = userAuthorization.components(separatedBy: ":")
guard credentials.count >= 2 else {
return onFailure(.badRequest, nil)
}
userid = credentials[0]
password = credentials[1]
}

verifyPassword(username: userid, password: password) { selfInstance in
if let selfInstance = selfInstance {
onSuccess(selfInstance)
} else {
onFailure(.unauthorized, ["WWW-Authenticate" : "Basic realm=\"" + self.realm + "\""])
}
}
}
}

// Trivial implementation of a type-safe basic authentication that only allows
// the user "John" with password "12345".
struct TestBasicAuthedUser: TypeSafeHTTPBasic {
static func verifyPassword(username: String, password: String, callback: @escaping (TestBasicAuthedUser?) -> Void) {
if username == "John" && password == "12345" {
callback(TestBasicAuthedUser(id: username))
} else {
callback(nil)
}
}
var id: String
}
40 changes: 40 additions & 0 deletions Tests/CredentialsJWTTests/Middlewares/TestMultiAuth.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Copyright IBM Corporation 2019
*
* 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 SwiftJWT
import Credentials
import CredentialsJWT

// Trivial multi-auth credentials that authenticates using either JWT or Basic
// credentials.
struct TestMultiAuth: TypeSafeMultiCredentials {
var id: String
var provider: String
var profile: JWT<TestClaims>?

static var authenticationMethods: [TypeSafeCredentials.Type] = [JWT<TestClaims>.self, TestBasicAuthedUser.self]

init(successfulAuth: TypeSafeCredentials) {
self.id = successfulAuth.id
self.provider = successfulAuth.provider
switch(successfulAuth.self) {
case let jwt as JWT<TestClaims>:
self.profile = jwt
default:
self.profile = nil
}
}
}
Loading

0 comments on commit 87dc061

Please sign in to comment.