Skip to content

Commit

Permalink
Adds JWT validation for Apple and Google (#114)
Browse files Browse the repository at this point in the history
* Adds JWKSCache and validation for apple and google

* cleanup

* Updated to know about must-revalidate, no-store and no-cache

* Renamed hd to hostDomain

* Updated jwt-kit dependency version

* Also checks for must-revalidate on cache now

* Algabreic simplification

* argh

* Updated to use EndpointCache

* Removed a test as it's in vapor/vapor now

* separate google + apple helpers

* request

* updates

Co-authored-by: Tanner <[email protected]>
  • Loading branch information
grosch and tanner0101 authored Feb 19, 2020
1 parent 0ecb6fa commit 0f79b27
Show file tree
Hide file tree
Showing 6 changed files with 276 additions and 154 deletions.
4 changes: 2 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ let package = Package(
.library(name: "JWT", targets: ["JWT"]),
],
dependencies: [
.package(url: "https://github.com/vapor/jwt-kit.git", from: "4.0.0-beta.2.3"),
.package(url: "https://github.com/vapor/vapor.git", from: "4.0.0-beta.3.17"),
.package(url: "https://github.com/vapor/jwt-kit.git", from: "4.0.0-beta.2.5"),
.package(url: "https://github.com/vapor/vapor.git", from: "4.0.0-beta.3.19"),
],
targets: [
.target(name: "JWT", dependencies: ["JWTKit", "Vapor"]),
Expand Down
137 changes: 1 addition & 136 deletions Sources/JWT/JWKSCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,139 +2,4 @@ import Vapor

/// A thread-safe and atomic class for retrieving JSON Web Key Sets which honors the
/// HTTP `Cache-Control`, `Expires` and `Etag` headers.
public final class JWKSCache {
public enum Error: Swift.Error {
case missingCache
case unexpctedResponseStatus(HTTPStatus, uri: URI)
}
private let uri: URI
private let client: Client
private let sync: Lock

struct CachedJWKS {
var cacheUntil: Date
var jwks: JWKS
}

private var cachedETag: String?
private var cachedJWKS: CachedJWKS?
private var currentRequest: EventLoopFuture<JWKS>?

/// Creates a new `JWKSCache`.
/// - Parameters:
/// - keyURL: The URL to the JWKS data.
/// - application: The Vapor `Application`.
public init(keyURL: String, client: Client) {
self.uri = URI(string: keyURL)
self.client = client
self.sync = .init()
}

/// Downloads the JSON Web Key Set, taking into account `Cache-Control`, `Expires` and `Etag` headers..
/// - Parameters:
/// - req: The Vapor `Request` object.
public func keys(on request: Request) -> EventLoopFuture<JWKS> {
self.keys(logger: request.logger, on: request.eventLoop)
}

/// Downloads the JSON Web Key Set, taking into account `Cache-Control`, `Expires` and `Etag` headers..
/// - Parameters:
/// - logger: For logging debug messages.
/// - eventLoop: Event loop to be called back on.
public func keys(logger: Logger, on eventLoop: EventLoop) -> EventLoopFuture<JWKS> {
// Synchronize access to shared state.
self.sync.lock()
defer { self.sync.unlock() }

// Check if we have cached keys that are still valid.
if let cachedJWKS = self.cachedJWKS, Date() < cachedJWKS.cacheUntil {
return eventLoop.makeSucceededFuture(cachedJWKS.jwks)
}

// Check if there is already a request happening
// to fetch keys.
if let keys = self.currentRequest {
// The current key request may be happening on a
// different event loop.
return keys.hop(to: eventLoop)
}

// Create a new key request and store it.
logger.debug("Requesting JWKS from \(self.uri).")
let keys = self.requestKeys(logger: logger)
self.currentRequest = keys

// Once the key request finishes, clear the current
// request and return the keys.
return keys.map { keys in
// Synchronize access to shared state.
self.sync.lock()
defer { self.sync.unlock() }
self.currentRequest = nil
return keys
}.hop(to: eventLoop)
}

private func requestKeys(logger: Logger) -> EventLoopFuture<JWKS> {
// Add cached eTag header to this request if we have it.
var headers: HTTPHeaders = [:]
if let eTag = self.cachedETag {
headers.add(name: .ifNoneMatch, value: eTag)
}

// Store the requested-at date to calculate expiration date.
let requestSentAt = Date()

// Send the GET request for the JWKs.
return self.client.get(
self.uri, headers: headers
).flatMapThrowing { response in
// Synchronize access to shared state.
self.sync.lock()
defer { self.sync.unlock() }

let expirationDate = response.headers.expirationDate(requestSentAt: requestSentAt)
self.cachedETag = response.headers.firstValue(name: .eTag)
switch response.status {
case .notModified:
// The cached JWKS are still the latest version.
logger.debug("Cached JWKS are still valid.")
guard var cachedJWKS = self.cachedJWKS else {
throw Error.missingCache
}

// Update the JWKS cache if there is an expiration date.
if let expirationDate = expirationDate {
// Update the cache metadata.
cachedJWKS.cacheUntil = expirationDate
self.cachedJWKS = cachedJWKS
} else {
self.cachedJWKS = nil
}
return cachedJWKS.jwks
case .ok:
// New JWKS have been returned.
logger.debug("New JWKS have been returned.")
let jwks = try response.content.decode(JWKS.self)

// Cache the JWKS if there is an expiration date.
if let expirationDate = expirationDate {
if var cachedJWKS = self.cachedJWKS {
// Update the existing cache.
cachedJWKS.cacheUntil = expirationDate
cachedJWKS.jwks = jwks
self.cachedJWKS = cachedJWKS
} else {
// Create a new cache.
self.cachedJWKS = .init(cacheUntil: expirationDate, jwks: jwks)
}
} else {
self.cachedJWKS = nil
}
return jwks
default:
throw Error.unexpctedResponseStatus(response.status, uri: self.uri)
}
}
}
}
public typealias JWKSCache = EndpointCache<JWKS>
102 changes: 102 additions & 0 deletions Sources/JWT/JWT+Apple.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import Vapor

extension Request.JWT {
public var apple: Apple {
.init(request: self.request)
}

public struct Apple {
let request: Request

public func verify(applicationIdentifier: String? = nil) -> EventLoopFuture<AppleIdentityToken> {
guard let token = self.request.headers.bearerAuthorization?.token else {
self.request.logger.error("Request is missing JWT bearer header.")
return self.request.eventLoop.makeFailedFuture(Abort(.unauthorized))
}
return self.verify(token, applicationIdentifier: applicationIdentifier)
}

public func verify(_ message: String, applicationIdentifier: String? = nil) -> EventLoopFuture<AppleIdentityToken> {
self.verify([UInt8](message.utf8), applicationIdentifier: applicationIdentifier)
}

public func verify<Message>(_ message: Message, applicationIdentifier: String? = nil) -> EventLoopFuture<AppleIdentityToken>
where Message: DataProtocol
{
self.request.application.jwt.apple.signers(
on: self.request
).flatMapThrowing { signers in
let token = try signers.verify(message, as: AppleIdentityToken.self)
if let applicationIdentifier = applicationIdentifier ?? self.request.application.jwt.apple.applicationIdentifier {
guard token.audience.value == applicationIdentifier else {
throw JWTError.claimVerificationFailure(
name: "audience",
reason: "Audience claim does not match application identifier"
)
}
}
return token
}
}
}
}

extension Application.JWT {
public var apple: Apple {
.init(jwt: self)
}

public struct Apple {
let jwt: Application.JWT

public func signers(on request: Request) -> EventLoopFuture<JWTSigners> {
self.jwks.get(on: request).flatMapThrowing {
let signers = JWTSigners()
try signers.use(jwks: $0)
return signers
}
}

public var jwks: EndpointCache<JWKS> {
self.storage.jwks
}

public var applicationIdentifier: String? {
get {
self.storage.applicationIdentifier
}
nonmutating set {
self.storage.applicationIdentifier = newValue
}
}

private struct Key: StorageKey, LockKey {
typealias Value = Storage
}

private final class Storage {
let jwks: EndpointCache<JWKS>
var applicationIdentifier: String?
init() {
self.jwks = .init(uri: "https://appleid.apple.com/auth/keys")
self.applicationIdentifier = nil
}
}

private var storage: Storage {
if let existing = self.jwt.application.storage[Key.self] {
return existing
} else {
let lock = self.jwt.application.locks.lock(for: Key.self)
lock.lock()
defer { lock.unlock() }
if let existing = self.jwt.application.storage[Key.self] {
return existing
}
let new = Storage()
self.jwt.application.storage[Key.self] = new
return new
}
}
}
}
142 changes: 142 additions & 0 deletions Sources/JWT/JWT+Google.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import Vapor

extension Request.JWT {
public var google: Google {
.init(request: self.request)
}

public struct Google {
let request: Request

public func verify(
applicationIdentifier: String? = nil,
gSuiteDomainName: String? = nil
) -> EventLoopFuture<GoogleIdentityToken> {
guard let token = self.request.headers.bearerAuthorization?.token else {
self.request.logger.error("Request is missing JWT bearer header.")
return self.request.eventLoop.makeFailedFuture(Abort(.unauthorized))
}
return self.verify(
token,
applicationIdentifier: applicationIdentifier,
gSuiteDomainName: gSuiteDomainName
)
}

public func verify(
_ message: String,
applicationIdentifier: String? = nil,
gSuiteDomainName: String? = nil
) -> EventLoopFuture<GoogleIdentityToken> {
self.verify(
[UInt8](message.utf8),
applicationIdentifier: applicationIdentifier,
gSuiteDomainName: gSuiteDomainName
)
}

public func verify<Message>(
_ message: Message,
applicationIdentifier: String? = nil,
gSuiteDomainName: String? = nil
) -> EventLoopFuture<GoogleIdentityToken>
where Message: DataProtocol
{
self.request.application.jwt.google.signers(
on: self.request
).flatMapThrowing { signers in
let token = try signers.verify(message, as: GoogleIdentityToken.self)
if let applicationIdentifier = applicationIdentifier ?? self.request.application.jwt.google.applicationIdentifier {
guard token.audience.value == applicationIdentifier else {
throw JWTError.claimVerificationFailure(
name: "audience",
reason: "Audience claim does not match application identifier"
)
}

}
if let gSuiteDomainName = gSuiteDomainName ?? self.request.application.jwt.google.gSuiteDomainName {
guard let hd = token.hostedDomain, hd.value == gSuiteDomainName else {
throw JWTError.claimVerificationFailure(
name: "hostedDomain",
reason: "Hosted domain claim does not match gSuite domain name"
)
}
}
return token
}
}

}
}

extension Application.JWT {
public var google: Google {
.init(jwt: self)
}

public struct Google {
let jwt: Application.JWT

public func signers(on request: Request) -> EventLoopFuture<JWTSigners> {
self.jwks.get(on: request).flatMapThrowing {
let signers = JWTSigners()
try signers.use(jwks: $0)
return signers
}
}

public var jwks: EndpointCache<JWKS> {
self.storage.jwks
}

public var applicationIdentifier: String? {
get {
self.storage.applicationIdentifier
}
nonmutating set {
self.storage.applicationIdentifier = newValue
}
}

public var gSuiteDomainName: String? {
get {
self.storage.gSuiteDomainName
}
nonmutating set {
self.storage.gSuiteDomainName = newValue
}
}

private struct Key: StorageKey, LockKey {
typealias Value = Storage
}

private final class Storage {
let jwks: EndpointCache<JWKS>
var applicationIdentifier: String?
var gSuiteDomainName: String?
init() {
self.jwks = .init(uri: "https://www.googleapis.com/oauth2/v3/certs")
self.applicationIdentifier = nil
self.gSuiteDomainName = nil
}
}

private var storage: Storage {
if let existing = self.jwt.application.storage[Key.self] {
return existing
} else {
let lock = self.jwt.application.locks.lock(for: Key.self)
lock.lock()
defer { lock.unlock() }
if let existing = self.jwt.application.storage[Key.self] {
return existing
}
let new = Storage()
self.jwt.application.storage[Key.self] = new
return new
}
}
}
}
Loading

0 comments on commit 0f79b27

Please sign in to comment.