Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Deprecate Implicit Grant Flow and Add PKCE Support #1

Merged
merged 6 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
ubuntu_test:
name: Ubuntu Build & Test
runs-on: ubuntu-22.04
container: swift:5.7.3-jammy
container: swift:6.0
steps:
- uses: actions/checkout@v3
- name: Build
Expand All @@ -22,8 +22,8 @@ jobs:
macos_test:
name: macOS Build & Test
env:
DEVELOPER_DIR: /Applications/Xcode_14.2.app/Contents/Developer
runs-on: macos-12
DEVELOPER_DIR: /Applications/Xcode_16.0.app/Contents/Developer
runs-on: macos-14
steps:
- uses: actions/checkout@v3
- name: Build
Expand Down
5 changes: 3 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ let package = Package(
)
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.106.0")
.package(url: "https://github.com/vapor/vapor.git", from: "4.106.0"),
.package(url: "https://github.com/apple/swift-crypto.git", from: "3.8.1")
],
targets: [
.target(
name: "VaporOAuth",
dependencies: [.product(name: "Vapor", package: "vapor")],
dependencies: [.product(name: "Vapor", package: "vapor"), .product(name: "Crypto", package: "swift-crypto")],
swiftSettings: [
.enableUpcomingFeature("BareSlashRegexLiterals"),
.enableExperimentalFeature("StrictConcurrency=complete"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ public struct EmptyCodeManager: CodeManager {
userID: String,
clientID: String,
redirectURI: String,
scopes: [String]?
scopes: [String]?,
codeChallenge: String?,
codeChallengeMethod: String?
) throws -> String {
return ""
}
Expand Down
11 changes: 8 additions & 3 deletions Sources/VaporOAuth/Models/OAuthCode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,27 @@ public final class OAuthCode: @unchecked Sendable {
public let userID: String
public let expiryDate: Date
public let scopes: [String]?

public let codeChallenge: String?
public let codeChallengeMethod: String?
public var extend: [String: Any] = [:]

public init(
codeID: String,
clientID: String,
redirectURI: String,
userID: String,
expiryDate: Date,
scopes: [String]?
scopes: [String]?,
codeChallenge: String?,
codeChallengeMethod: String?
) {
self.codeID = codeID
self.clientID = clientID
self.redirectURI = redirectURI
self.userID = userID
self.expiryDate = expiryDate
self.scopes = scopes
self.codeChallenge = codeChallenge
self.codeChallengeMethod = codeChallengeMethod
}
}
15 changes: 15 additions & 0 deletions Sources/VaporOAuth/Protocols/AuthorizeHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public enum AuthorizationError: Error, Sendable {
case confidentialClientTokenGrant
case invalidRedirectURI
case httpRedirectURI
case missingPKCE
}

public struct AuthorizationRequestObject: Sendable {
Expand All @@ -22,4 +23,18 @@ public struct AuthorizationRequestObject: Sendable {
public let scope: [String]
public let state: String?
public let csrfToken: String
// PKCE parameters
public let codeChallenge: String?
public let codeChallengeMethod: String?

public init(responseType: String, clientID: String, redirectURI: URI, scope: [String], state: String?, csrfToken: String, codeChallenge: String?, codeChallengeMethod: String?) {
self.responseType = responseType
self.clientID = clientID
self.redirectURI = redirectURI
self.scope = scope
self.state = state
self.csrfToken = csrfToken
self.codeChallenge = codeChallenge
self.codeChallengeMethod = codeChallengeMethod
}
}
2 changes: 1 addition & 1 deletion Sources/VaporOAuth/Protocols/CodeManager.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// Responsible for generating and managing OAuth Codes
public protocol CodeManager: Sendable {
func generateCode(userID: String, clientID: String, redirectURI: String, scopes: [String]?) async throws -> String
func generateCode(userID: String, clientID: String, redirectURI: String, scopes: [String]?, codeChallenge: String?, codeChallengeMethod: String?) async throws -> String
func getCode(_ code: String) async throws -> OAuthCode?

// This is explicit to ensure that the code is marked as used or deleted (it could be implied that this is done when you call
Expand Down
36 changes: 34 additions & 2 deletions Sources/VaporOAuth/RouteHandlers/AuthorizeGetHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,12 @@ struct AuthorizeGetHandler: Sendable {
let csrfToken = [UInt8].random(count: 32).hex

request.session.data[SessionData.csrfToken] = csrfToken

let authorizationRequestObject = AuthorizationRequestObject(responseType: authRequestObject.responseType,
clientID: authRequestObject.clientID, redirectURI: redirectURI,
scope: authRequestObject.scopes, state: authRequestObject.state,
csrfToken: csrfToken)
csrfToken: csrfToken, codeChallenge: authRequestObject.codeChallenge,
codeChallengeMethod: authRequestObject.codeChallengeMethod)

return try await authorizeHandler.handleAuthorizationRequest(request, authorizationRequestObject: authorizationRequestObject)
}
Expand Down Expand Up @@ -98,9 +100,37 @@ struct AuthorizeGetHandler: Sendable {
return (errorResponse, nil)
}

let codeChallenge: String? = request.query[OAuthRequestParameters.codeChallenge]
let codeChallengeMethod: String? = request.query[OAuthRequestParameters.codeChallengeMethod]

// PKCE Validation
if let codeChallenge = codeChallenge {
if let codeChallengeMethod = codeChallengeMethod {
if !(codeChallengeMethod == "plain" || codeChallengeMethod == "S256") {
// Invalid codeChallengeMethod
let errorResponse = createErrorResponse(request: request,
redirectURI: redirectURIString,
errorType: OAuthResponseParameters.ErrorType.invalidRequest,
errorDescription: "Invalid code challenge method",
state: state)
return (errorResponse, nil)
}
} else {
// codeChallengeMethod is missing
let errorResponse = createErrorResponse(request: request,
redirectURI: redirectURIString,
errorType: OAuthResponseParameters.ErrorType.invalidRequest,
errorDescription: "Code challenge method is required when code challenge is provided",
state: state)
return (errorResponse, nil)
}
}

let authRequestObject = AuthorizationGetRequestObject(clientID: clientID, redirectURIString: redirectURIString,
scopes: scopes, state: state,
responseType: responseType)
responseType: responseType,
codeChallenge: codeChallenge,
codeChallengeMethod: codeChallengeMethod)

return (nil, authRequestObject)
}
Expand Down Expand Up @@ -128,4 +158,6 @@ struct AuthorizationGetRequestObject: Sendable {
let scopes: [String]
let state: String?
let responseType: String
let codeChallenge: String?
let codeChallengeMethod: String?
}
14 changes: 12 additions & 2 deletions Sources/VaporOAuth/RouteHandlers/AuthorizePostHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ struct AuthorizePostRequest: Sendable {
let responseType: String
let csrfToken: String
let scopes: [String]?
let codeChallenge: String?
let codeChallengeMethod: String?
}

struct AuthorizePostHandler: Sendable {
Expand Down Expand Up @@ -49,7 +51,9 @@ struct AuthorizePostHandler: Sendable {
userID: requestObject.userID,
clientID: requestObject.clientID,
redirectURI: requestObject.redirectURIBaseString,
scopes: requestObject.scopes
scopes: requestObject.scopes,
codeChallenge: requestObject.codeChallenge,
codeChallengeMethod: requestObject.codeChallengeMethod
)
redirectURI += "?code=\(generatedCode)"
} else {
Expand Down Expand Up @@ -107,9 +111,15 @@ struct AuthorizePostHandler: Sendable {
scopes = nil
}

let codeChallenge: String? = request.query[String.self, at: OAuthRequestParameters.codeChallenge]

let codeChallengeMethod: String? = request.query[String.self, at: OAuthRequestParameters.codeChallengeMethod]


return AuthorizePostRequest(user: user, userID: userID, redirectURIBaseString: redirectURIBaseString,
approveApplication: approveApplication, clientID: clientID,
responseType: responseType, csrfToken: csrfToken, scopes: scopes)
responseType: responseType, csrfToken: csrfToken, scopes: scopes,
codeChallenge: codeChallenge, codeChallengeMethod: codeChallengeMethod)
}

}
Original file line number Diff line number Diff line change
@@ -1,57 +1,79 @@
import Vapor

struct AuthCodeTokenHandler {

let clientValidator: ClientValidator
let tokenManager: TokenManager
let codeManager: CodeManager
let codeValidator = CodeValidator()
let tokenResponseGenerator: TokenResponseGenerator

func handleAuthCodeTokenRequest(_ request: Request) async throws -> Response {
// Parameter validation
guard let codeString: String = request.content[OAuthRequestParameters.code] else {
return try tokenResponseGenerator.createResponse(error: OAuthResponseParameters.ErrorType.invalidRequest,
description: "Request was missing the 'code' parameter")
return try tokenResponseGenerator.createResponse(
error: OAuthResponseParameters.ErrorType.invalidRequest,
description: "Request was missing the 'code' parameter"
)
}

guard let redirectURI: String = request.content[OAuthRequestParameters.redirectURI] else {
return try tokenResponseGenerator.createResponse(error: OAuthResponseParameters.ErrorType.invalidRequest,
description: "Request was missing the 'redirect_uri' parameter")
return try tokenResponseGenerator.createResponse(
error: OAuthResponseParameters.ErrorType.invalidRequest,
description: "Request was missing the 'redirect_uri' parameter"
)
}

guard let clientID: String = request.content[OAuthRequestParameters.clientID] else {
return try tokenResponseGenerator.createResponse(error: OAuthResponseParameters.ErrorType.invalidRequest,
description: "Request was missing the 'client_id' parameter")
return try tokenResponseGenerator.createResponse(
error: OAuthResponseParameters.ErrorType.invalidRequest,
description: "Request was missing the 'client_id' parameter"
)
}


// Client authentication
do {
try await clientValidator.authenticateClient(clientID: clientID,
clientSecret: request.content[String.self, at: OAuthRequestParameters.clientSecret],
grantType: .authorization)
try await clientValidator.authenticateClient(
clientID: clientID,
clientSecret: request.content[String.self, at: OAuthRequestParameters.clientSecret],
grantType: .authorization
)
} catch {
return try tokenResponseGenerator.createResponse(error: OAuthResponseParameters.ErrorType.invalidClient,
description: "Request had invalid client credentials", status: .unauthorized)
return try tokenResponseGenerator.createResponse(
error: OAuthResponseParameters.ErrorType.invalidClient,
description: "Request had invalid client credentials",
status: .unauthorized
)
}


// Code retrieval and all validations
guard let code = try await codeManager.getCode(codeString),
codeValidator.validateCode(code, clientID: clientID, redirectURI: redirectURI) else {
let errorDescription = "The code provided was invalid or expired, or the redirect URI did not match"
return try tokenResponseGenerator.createResponse(error: OAuthResponseParameters.ErrorType.invalidGrant,
description: errorDescription)
codeValidator.validateBasicRequirements(code, clientID: clientID, redirectURI: redirectURI),
codeValidator.validatePKCE(code, codeVerifier: request.content[OAuthRequestParameters.codeVerifier]) else {
return try tokenResponseGenerator.createResponse(
error: OAuthResponseParameters.ErrorType.invalidGrant,
description: "The code provided was invalid or expired, or the redirect URI did not match"
)
}


// Mark code as used after successful validation
try await codeManager.codeUsed(code)

let scopes = code.scopes
// Generate tokens
let expiryTime = 3600

let (access, refresh) = try await tokenManager.generateAccessRefreshTokens(
clientID: clientID, userID: code.userID,
scopes: scopes,
clientID: clientID,
userID: code.userID,
scopes: code.scopes,
accessTokenExpiryTime: expiryTime
)

return try tokenResponseGenerator.createResponse(accessToken: access, refreshToken: refresh, expires: Int(expiryTime),
scope: scopes?.joined(separator: " "))

// Return successful response
return try tokenResponseGenerator.createResponse(
accessToken: access,
refreshToken: refresh,
expires: Int(expiryTime),
scope: code.scopes?.joined(separator: " ")
)
}
}
5 changes: 5 additions & 0 deletions Sources/VaporOAuth/Utilities/OAuthFlowType.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
public enum OAuthFlowType: String, Sendable {
case authorization = "authorization_code"

@available(*, deprecated, message: "The Implicit Grant flow is deprecated and not recommended for security reasons. It returns access tokens in HTTP redirects without confirmation of client receipt. Native and browser-based apps should use Authorization Code flow with PKCE instead, as recommended by OAuth 2.0 Security Best Practices.")
case implicit = "implicit"

@available(*, deprecated, message: "The Password Grant flow is deprecated and not recommended for security reasons. It is disallowed in OAuth 2.1 and exposes user credentials. Use Authorization Code flow with PKCE instead.")
case password = "password"

case clientCredentials = "client_credentials"
case refresh = "refresh_token"
case tokenIntrospection = "token_introspection"
Expand Down
3 changes: 3 additions & 0 deletions Sources/VaporOAuth/Utilities/StringDefines.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ struct OAuthRequestParameters: Sendable {
static let usernname = "username"
static let csrfToken = "csrfToken"
static let token = "token"
static let codeChallenge = "code_challenge"
static let codeChallengeMethod = "code_challenge_method"
static let codeVerifier = "code_verifier"
}

struct OAuthResponseParameters: Sendable {
Expand Down
37 changes: 25 additions & 12 deletions Sources/VaporOAuth/Validators/CodeValidator.swift
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
import Foundation
import Crypto

struct CodeValidator {
func validateCode(_ code: OAuthCode, clientID: String, redirectURI: String) -> Bool {
guard code.clientID == clientID else {
return false
}

guard code.expiryDate >= Date() else {
return false
}

guard code.redirectURI == redirectURI else {
return false
/// Validates the basic OAuth code requirements (mandatory)
func validateBasicRequirements(_ code: OAuthCode, clientID: String, redirectURI: String) -> Bool {
return code.clientID == clientID &&
code.redirectURI == redirectURI &&
code.expiryDate >= Date()
}

/// Validates PKCE if used (optional enhancement)
func validatePKCE(_ code: OAuthCode, codeVerifier: String?) -> Bool {
// If code challenge was used in authorization request
if let codeChallenge = code.codeChallenge,
let codeChallengeMethod = code.codeChallengeMethod {
// Code verifier is required when code challenge was used
guard let verifier = codeVerifier else {
return false
}

return PKCEValidator().validate(
codeVerifier: verifier,
codeChallenge: codeChallenge,
codeChallengeMethod: codeChallengeMethod
)
}


// PKCE wasn't used in authorization request, so no verification needed
return true
}
}
Loading
Loading