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

Implementation of Device Code Flow in Vapor OAuth #28

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
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
68 changes: 34 additions & 34 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,44 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/async-http-client.git",
"state" : {
"revision" : "fc510a39cff61b849bf5cdff17eb2bd6d0777b49",
"version" : "1.11.5"
"revision" : "16f7e62c08c6969899ce6cc277041e868364e5cf",
"version" : "1.19.0"
}
},
{
"identity" : "async-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/async-kit.git",
"state" : {
"revision" : "c3329e444bafbb12d1d312af9191be95348a8175",
"version" : "1.13.0"
"revision" : "eab9edff78e8ace20bd7cb6e792ab46d54f59ab9",
"version" : "1.18.0"
}
},
{
"identity" : "console-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/console-kit.git",
"state" : {
"revision" : "a7e67a1719933318b5ab7eaaed355cde020465b1",
"version" : "4.5.0"
"revision" : "9a12000f4064a2bdc49068d7258292ec1bdc88fc",
"version" : "4.7.0"
}
},
{
"identity" : "multipart-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/multipart-kit.git",
"state" : {
"revision" : "0d55c35e788451ee27222783c7d363cb88092fab",
"version" : "4.5.2"
"revision" : "1adfd69df2da08f7931d4281b257475e32c96734",
"version" : "4.5.4"
}
},
{
"identity" : "routing-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/routing-kit.git",
"state" : {
"revision" : "ffac7b3a127ce1e85fb232f1a6271164628809ad",
"version" : "4.6.0"
"revision" : "e0539da5b60a60d7381f44cdcf04036f456cee2f",
"version" : "4.8.0"
}
},
{
Expand All @@ -59,8 +59,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe",
"version" : "1.0.2"
"revision" : "6c89474e62719ddcc1e9614989fff2f68208fe10",
"version" : "1.1.0"
}
},
{
Expand All @@ -77,80 +77,80 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "f504716c27d2e5d4144fa4794b12129301d17729",
"version" : "1.0.3"
"revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2",
"version" : "1.0.4"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "d9825fa541df64b1a7b182178d61b9a82730d01f",
"version" : "2.1.0"
"revision" : "60f13f60c4d093691934dc6cfdf5f508ada1f894",
"version" : "2.6.0"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "6fe203dc33195667ce1759bf0182975e4653ba1c",
"version" : "1.4.4"
"revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed",
"version" : "1.5.3"
}
},
{
"identity" : "swift-metrics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-metrics.git",
"state" : {
"revision" : "53be78637ecd165d1ddedc4e20de69b8f43ec3b7",
"version" : "2.3.2"
"revision" : "971ba26378ab69c43737ee7ba967a896cb74c0d1",
"version" : "2.4.1"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "b4e0a274f7f34210e97e2f2c50ab02a10b549250",
"version" : "2.41.1"
"revision" : "cf281631ff10ec6111f2761052aa81896a83a007",
"version" : "2.58.0"
}
},
{
"identity" : "swift-nio-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-extras.git",
"state" : {
"revision" : "6c84d247754ad77487a6f0694273b89b83efd056",
"version" : "1.14.0"
"revision" : "0e0d0aab665ff1a0659ce75ac003081f2b1c8997",
"version" : "1.19.0"
}
},
{
"identity" : "swift-nio-http2",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-http2.git",
"state" : {
"revision" : "f9ab1c94c80d568efd762d2a638f25162691d766",
"version" : "1.22.1"
"revision" : "a8ccf13fa62775277a5d56844878c828bbb3be1a",
"version" : "1.27.0"
}
},
{
"identity" : "swift-nio-ssl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-ssl.git",
"state" : {
"revision" : "ba7c0d7f82affc518147ea61d240330bf7f7ea9b",
"version" : "2.22.1"
"revision" : "320bd978cceb8e88c125dcbb774943a92f6286e9",
"version" : "2.25.0"
}
},
{
"identity" : "swift-nio-transport-services",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-transport-services.git",
"state" : {
"revision" : "4e02d9cf35cabfb538c96613272fb027dd0c8692",
"version" : "1.13.1"
"revision" : "e7403c35ca6bb539a7ca353b91cc2d8ec0362d58",
"version" : "1.19.0"
}
},
{
Expand All @@ -167,17 +167,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/vapor.git",
"state" : {
"revision" : "dda0de537e7906414dccd551e77095be1e34e3da",
"version" : "4.65.2"
"revision" : "1bb4a2ed94bec7a92f92e82896408c785d068f5c",
"version" : "4.79.0"
}
},
{
"identity" : "websocket-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/websocket-kit.git",
"state" : {
"revision" : "2d9d2188a08eef4a869d368daab21b3c08510991",
"version" : "2.6.1"
"revision" : "53fe0639a98903858d0196b699720decb42aee7b",
"version" : "2.14.0"
}
}
],
Expand Down
10 changes: 10 additions & 0 deletions Sources/VaporOAuth/DefaultImplementations/EmptyCodeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,14 @@ public struct EmptyCodeManager: CodeManager {
}

public func codeUsed(_ code: OAuthCode) {}

public func getDeviceCode(_ deviceCode: String) -> OAuthDeviceCode? {
return nil
}

public func generateDeviceCode(userID: String, clientID: String, scopes: [String]?) throws -> String {
return ""
}

public func deviceCodeUsed(_ deviceCode: OAuthDeviceCode) {}
}
32 changes: 32 additions & 0 deletions Sources/VaporOAuth/Models/OAuthDeviceCode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

public final class OAuthDeviceCode {
public let deviceCodeID: String
public let userCode: String
public let clientID: String
public let userID: String?
public let expiryDate: Date
public let scopes: [String]?

public var extend: [String: Any] = [:]

public init(
deviceCodeID: String,
userCode: String,
clientID: String,
userID: String?,
expiryDate: Date,
scopes: [String]?
) {
self.deviceCodeID = deviceCodeID
self.userCode = userCode
self.clientID = clientID
self.userID = userID
self.expiryDate = expiryDate
self.scopes = scopes
}

public var isExpired: Bool {
return Date() > expiryDate
}
}
41 changes: 38 additions & 3 deletions Sources/VaporOAuth/Protocols/CodeManager.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,44 @@
/// Responsible for generating and managing OAuth Codes
public protocol CodeManager {

/// Generates an OAuth code for the specified user, client, redirect URI, and scopes.
/// - Parameters:
/// - userID: The ID of the user.
/// - clientID: The ID of the client.
/// - redirectURI: The redirect URI for the client.
/// - scopes: Optional array of scopes.
/// - Returns: The generated OAuth code.
/// - Throws: An error if the code generation fails.
func generateCode(userID: String, clientID: String, redirectURI: String, scopes: [String]?) async throws -> String

/// Retrieves the OAuth code associated with the specified code.
/// - Parameter code: The OAuth code.
/// - Returns: The associated OAuth code, or `nil` if not found.
/// - Throws: An error if the retrieval fails.
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
// `getCode` but it is called explicitly to remind developers to ensure that codes can't be reused)

/// Marks the specified OAuth code as used or deleted.
/// - Parameter code: The OAuth code to mark as used or deleted.
/// - Throws: An error if the operation fails.
func codeUsed(_ code: OAuthCode) async throws

/// Generates a device code for the specified user, client, and scopes.
/// - Parameters:
/// - userID: The ID of the user.
/// - clientID: The ID of the client.
/// - scopes: Optional array of scopes.
/// - Returns: The generated device code.
/// - Throws: An error if the code generation fails.
func generateDeviceCode(userID: String, clientID: String, scopes: [String]?) async throws -> String

/// Retrieves the device code associated with the specified code.
/// - Parameter deviceCode: The device code.
/// - Returns: The associated device code, or `nil` if not found.
/// - Throws: An error if the retrieval fails.
func getDeviceCode(_ deviceCode: String) async throws -> OAuthDeviceCode?

/// Marks the specified device code as used or deleted.
/// - Parameter deviceCode: The device code to mark as used or deleted.
/// - Throws: An error if the operation fails.
func deviceCodeUsed(_ deviceCode: OAuthDeviceCode) async throws
}
6 changes: 6 additions & 0 deletions Sources/VaporOAuth/RouteHandlers/TokenHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ struct TokenHandler {
let tokenResponseGenerator: TokenResponseGenerator
let authCodeTokenHandler: AuthCodeTokenHandler
let passwordTokenHandler: PasswordTokenHandler
let deviceCodeTokenHandler: DeviceCodeTokenHandler

init(clientValidator: ClientValidator, tokenManager: TokenManager, scopeValidator: ScopeValidator,
codeManager: CodeManager, userManager: UserManager, logger: Logger) {
Expand All @@ -25,6 +26,9 @@ struct TokenHandler {
passwordTokenHandler = PasswordTokenHandler(clientValidator: clientValidator, scopeValidator: scopeValidator,
userManager: userManager, logger: logger, tokenManager: tokenManager,
tokenResponseGenerator: tokenResponseGenerator)
deviceCodeTokenHandler = DeviceCodeTokenHandler(clientValidator: clientValidator, scopeValidator: scopeValidator, codeManager: codeManager,
tokenManager: tokenManager,
tokenResponseGenerator: tokenResponseGenerator)
}

func handleRequest(request: Request) async throws -> Response {
Expand All @@ -42,6 +46,8 @@ struct TokenHandler {
return try await clientCredentialsTokenHandler.handleClientCredentialsTokenRequest(request)
case OAuthFlowType.refresh.rawValue:
return try await refreshTokenHandler.handleRefreshTokenRequest(request)
case OAuthFlowType.deviceCode.rawValue:
return try await deviceCodeTokenHandler.handleDeviceCodeTokenRequest(request)
default:
return try tokenResponseGenerator.createResponse(error: OAuthResponseParameters.ErrorType.unsupportedGrant,
description: "This server does not support the '\(grantType)' grant type")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Vapor

struct DeviceCodeTokenHandler {

let clientValidator: ClientValidator
let scopeValidator: ScopeValidator
let codeManager: CodeManager
let tokenManager: TokenManager
let tokenResponseGenerator: TokenResponseGenerator

func handleDeviceCodeTokenRequest(_ request: Request) async throws -> Response {
guard let deviceCodeString: String = request.content[OAuthRequestParameters.deviceCode] else {
return try tokenResponseGenerator.createResponse(error: OAuthResponseParameters.ErrorType.invalidRequest,
description: "Request was missing the 'device_code' 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")
}

do {
try await clientValidator.authenticateClient(clientID: clientID, clientSecret: nil,
grantType: .deviceCode)
} catch {
return try tokenResponseGenerator.createResponse(error: OAuthResponseParameters.ErrorType.invalidClient,
description: "Request had invalid client credentials", status: .unauthorized)
}

guard let deviceCode = try await codeManager.getDeviceCode(deviceCodeString) else {
let errorDescription = "The device code provided was invalid or expired"
return try tokenResponseGenerator.createResponse(error: OAuthResponseParameters.ErrorType.invalidGrant,
description: errorDescription)
}

if deviceCode.expiryDate < Date() {
let errorDescription = "The device code provided was invalid or expired"
return try tokenResponseGenerator.createResponse(error: "expired_token",
description: errorDescription)
}

if let scopes = deviceCode.scopes {
do {
try await scopeValidator.validateScope(clientID: clientID, scopes: scopes)
} catch ScopeError.invalid, ScopeError.unknown {
return try tokenResponseGenerator.createResponse(error: OAuthResponseParameters.ErrorType.invalidScope,
description: "Request contained an invalid or unknown scope")
}
}

try await codeManager.deviceCodeUsed(deviceCode)

let expiryTime = 3600

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

return try tokenResponseGenerator.createResponse(accessToken: access, refreshToken: refresh, expires: Int(expiryTime),
scope: deviceCode.scopes?.joined(separator: " "))
}
}
1 change: 1 addition & 0 deletions Sources/VaporOAuth/Utilities/OAuthFlowType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ public enum OAuthFlowType: String {
case clientCredentials = "client_credentials"
case refresh = "refresh_token"
case tokenIntrospection = "token_introspection"
case deviceCode = "urn:ietf:params:oauth:grant-type:device_code"
}
2 changes: 2 additions & 0 deletions Sources/VaporOAuth/Utilities/StringDefines.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct OAuthRequestParameters {
static let usernname = "username"
static let csrfToken = "csrfToken"
static let token = "token"
static let deviceCode = "device_code"
}

struct OAuthResponseParameters {
Expand All @@ -39,6 +40,7 @@ struct OAuthResponseParameters {
static let unsupportedGrant = "unsupported_grant_type"
static let invalidGrant = "invalid_grant"
static let missingToken = "missing_token"
static let expiredToken = "expired_token"
}
}

Expand Down
Loading