JSON Web Tokens in Vapor Part 3: Middleware and Error Handling

404 Views

Thus far we've covered creating JWTs, signing them, and verifying them. Oftentimes in an authenticated app, we need to restrict access to certain routes based on whether or not a user is authenticated, which is were middleware comes in.

You can download parts 1 and 2 completed here.

Create a new file, and call it JSONWebTokenMiddleware.swift, and inside create a class of the same name, making it conform to the Middleware protocol. Add the following function to the class:

    func respond(to request: Request, chainingTo next: Responder) throws -> Response {
        if let token = request.headers["Authorization"]?.string {
            // we will verify the token here
        } else { return try Response(status: Status.forbidden, json: JSON(node: ["error": "no auth token", "status": 403])) }
    }

The respond function is needed to properly conform to the Middleware protocol. This is where we will write code which will intercept any requests to routes we choose to protect.

Inside the if let token = ... statement, add the following code:

    let verified = tokenIsVerified(token)
    if verified { return try next.respond(to: request) }
    else { return try Response(status: Status.forbidden, json: JSON(node: ["error": "unverified token", "status": 403])) }

The function, at this point, should look like:

    func respond(to request: Request, chainingTo next: Responder) throws -> Response {
    if let token = request.headers["Authorization"]?.string {
         let verified = tokenIsVerified(token) // (1)
         if verified { return try next.respond(to: request) } // (2)
         else { return try Response(status: Status.forbidden, json: JSON(node: ["error": "unverified token", "status": 403])) } // (3)
        } else { return try Response(status: Status.forbidden, json: JSON(node: ["error": "no auth token", "status": 403])) }
    }

At (1), we run a function that will give us a boolean value of whether or not the token verifies. Then at (2), if it verifies, we just pass the request down to the next responder (whether that be another middle ware, or back to the user). If it fails, then at (3) we return a JSON response with a status of 403 forbidden, with an error stating we are unauthorized. At this point, you can remove the verifyToken method and route in UserController.swift. We will no longer use it for testing.

Now, we can add the middleware to protect a route. In Routes.swift, in setupRoutes(), add the following:

    let jwtMiddleware = JSONWebTokenMiddleware()
    let authed = grouped(jwtMiddleware)
        
    authed.get("protectedroute") { request in
            return "authenticated"
    }

Now, if you try to access /protectedroute with no Authorization header, you will get the response:

    {"error": "no auth token", "status": 403}

And if you try to access `/protectedroute` with an `Authorization` header, but with an invalid token, you will get:

    {"error": "unverified token", "status": 403}    

This works, but there are still some things we can do to improve it. Let's add better, more custom error handling. Create a new file, JSONWebTokenErrors.swift. Copy the following enum into it:

    enum JWebTokenError: CustomStringConvertible, Error {
    case signatureVerificationFailedError
    case issuerVerificationFailedEror
    case tokenIsExpiredError
    case payloadCreationError
    case createJWTError
    
    var description: String {
        switch self {
            case .signatureVerificationFailedError:
                return "Could not verify signature"
            case .issuerVerificationFailedEror:
                return "Could not verify JWT issuer"
            case .tokenIsExpiredError:
                return "Your token is expired"
            case .payloadCreationError:
                return "Error creating JWT payload"
            case .createJWTError:
                return "Error creating JWT"
        }
      }
    }

Here we are creating some new error cases, with descriptions of the errors to pass in. We will use these errors in our previously defined helper functions, so that if an error is thrown anywhere along the way, we will get these more detailed error messages as a response, instead of just a generic "invalid token" error.

In TokenHelpers.swift, change the following 5 functions to read as the following:

    class func createPayload(from user: User) throws -> JSON {
        if let id = user.id?.int {
            let now = Date()
            let dateAsTimeDouble = now.timeIntervalSince1970
            let createdAt:Int = Int(dateAsTimeDouble)
            let expiration = Int(dateAsTimeDouble) + JWTConfig.expirationTime
            let payLoad = JSON(["iss": "vaporforums", "iat": .number(.int(createdAt)), "username": .string(user.username), "userId": .number(.int(id)), "exp": .number(.int(expiration))])
            return payLoad
        } else { throw JWebTokenError.payloadCreationError }
    }
    
    class func createJwt(from user: User) throws -> String {
        do {
            let payLoad = try TokenHelpers.createPayload(from: user)
            let headers = JWTConfig.headers
            let signer = JWTConfig.signer
            let jwt = try JWT(headers: headers, payload: payLoad, signer: signer)
            let token = try jwt.createToken()
            return token
        } catch { throw JWebTokenError.createJWTError }
    }
    
    class func canVerifySignature(withSigner signer: String, fromToken token: String) throws {
        let receivedJWT = try JWT(token: token)
        try receivedJWT.verifySignature(using: HS256(key: signer.bytes))
    }
    
    class func verifyIssuer(_ token: String) throws {
        let receivedJWT = try JWT(token: token)
        let issuerClaim = IssuerClaim(string: "vaporforums")
        try receivedJWT.verifyClaims([issuerClaim])
    }
    
    class func tokenIsExpired(_ token: String) throws {
        let receivedJWT = try JWT(token: token)
        try receivedJWT.verifyClaims([ExpirationTimeClaim(date: Date())])
    }

Here we change the definitions to make them "throwing" functions. (you will see numerous errors in the project now) This is also a more "swifty" approach to error handling. Lastly, change the tokenIsVerified method to read the following:

    class func tokenIsVerified(_ token: String) throws {
        do { try TokenHelpers.tokenIsExpired(token) }
        catch { throw JWebTokenError.tokenIsExpiredError }
        do { try TokenHelpers.verifyIssuer(token) }
        catch { throw JWebTokenError.issuerVerificationFailedEror }
        do { try TokenHelpers.canVerifySignature(withSigner: JWTConfig.signerKey, fromToken: token) }
        catch { throw JWebTokenError.signatureVerificationFailedError }
    }

Finally, go over to the respond function in your JSONWebTokenMiddleware.swift file, and change it to make use of our new error handling:

    func respond(to request: Request, chainingTo next: Responder) throws -> Response {
        if let token = request.headers["Authorization"]?.string {
            do { try TokenHelpers.tokenIsVerified(token) // (1)
                return try next.respond(to: request) }
            catch let error as JWebTokenError { // (2)
                return try Response(status: Status.forbidden, json: JSON(node: ["error": error.description, "status": 403]))
            }
        } else {
            return try Response(status: Status.forbidden, json: JSON(node: ["error": "no auth token", "status": 403]))
        }
    }

At (1), instead of getting the result of verification as a boolean, we simply try it with a call to a throwing function. If it passed, we pass the request down the chain of responders. If it fails, then the code jumps to the catch let error.. bracket, and we return JSON indicating a 403 response, and our custom error message as we defined in the JWebTokenError enum.

That's it for the post! In the next part, we will discuss how to retrieve values cleanly and efficiently from the payload of a JWT. You can find the project completed until this point here. Have a good one!



Enjoy this article? Consider supporting VaporForums by following us on Twitter! Get the latest Vapor articles, tutorials, and news.