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

Attestation-based client authentication support #107

Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,13 @@ public enum IdentityAndAccessManagementMetadata {
return URL(string: metaData.authorizationEndpoint ?? "")
}
}

var tokenEndpointAuthMethods: [String] {
switch self {
case .oidc(let metaData):
return metaData.tokenEndpointAuthMethodsSupported ?? []
case .oauth(let metaData):
return metaData.tokenEndpointAuthMethodsSupported ?? []
}
}
}
30 changes: 30 additions & 0 deletions Sources/Entities/JOSE/SignatureAlgorithm+Extentions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2023 European Commission
*
* 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 Foundation
import JOSESwift

public extension SignatureAlgorithm {
/// Returns `true` if the algorithm is NOT a MAC-based algorithm (HMAC).
var isNotMacAlgorithm: Bool {
switch self {
case .HS256, .HS384, .HS512:
return false // These are HMAC algorithms
default:
return true // All other algorithms are not MAC-based
}
}
}

1 change: 1 addition & 0 deletions Sources/Entities/Types/JWTClaimNames.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,5 @@ public extension JWTClaimNames {
static let type = "typ"
static let algorithm = "alg"
static let JWK = "jwk"
static let cnf = "cnf"
}
15 changes: 11 additions & 4 deletions Sources/Entities/Wallet/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,28 @@ public typealias ClientId = String
public typealias ClientSecret = String

public struct OpenId4VCIConfig {
public let clientId: ClientId
public let client: Client
public let authFlowRedirectionURI: URL
public let authorizeIssuanceConfig: AuthorizeIssuanceConfig
public let usePAR: Bool
public let dPoPConstructor: DPoPConstructorType?
public let clientAttestationPoPBuilder: ClientAttestationPoPBuilder?


public init(
clientId: ClientId,
client: Client,
authFlowRedirectionURI: URL,
authorizeIssuanceConfig: AuthorizeIssuanceConfig = .favorScopes,
usePAR: Bool = true
usePAR: Bool = true,
dPoPConstructor: DPoPConstructorType? = nil,
clientAttestationPoPBuilder: ClientAttestationPoPBuilder? = nil
) {
self.clientId = clientId
self.client = client
self.authFlowRedirectionURI = authFlowRedirectionURI
self.authorizeIssuanceConfig = authorizeIssuanceConfig
self.usePAR = usePAR
self.dPoPConstructor = dPoPConstructor
self.clientAttestationPoPBuilder = clientAttestationPoPBuilder
}
}

4 changes: 4 additions & 0 deletions Sources/Extensions/Dictionary+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
import Foundation
import SwiftyJSON

public func + (lhs: [String: String], rhs: [String: String]) -> [String: String] {
return lhs.merging(rhs) { (current, new) in new }
}

public extension Dictionary where Key == String, Value == Any {

func toThrowingJSONData() throws -> Data {
Expand Down
75 changes: 50 additions & 25 deletions Sources/Issuers/Issuer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public protocol IssuerType {
func authorizeWithPreAuthorizationCode(
credentialOffer: CredentialOffer,
authorizationCode: IssuanceAuthorization,
clientId: String,
client: Client,
transactionCode: String?,
authorizationDetailsInTokenRequest: AuthorizationDetailsInTokenRequest
) async -> Result<AuthorizedRequest, Error>
Expand Down Expand Up @@ -109,8 +109,12 @@ public actor Issuer: IssuerType {
dpopConstructor: dpopConstructor
)

try config.client.ensureSupportedByAuthorizationServer(
self.authorizationServerMetadata
)

issuanceRequester = IssuanceRequester(
issuerMetadata: issuerMetadata,
issuerMetadata: issuerMetadata,
poster: requesterPoster,
dpopConstructor: dpopConstructor
)
Expand All @@ -132,24 +136,24 @@ public actor Issuer: IssuerType {
let credentials = credentialOffer.credentialConfigurationIdentifiers
let issuerState: String? = switch credentialOffer.grants {
case .authorizationCode(let code),
.both(let code, _):
.both(let code, _):
code.issuerState
default:
nil
}

let (scopes, credentialConfogurationIdentifiers) = try scopesAndCredentialConfigurationIds(credentialOffer: credentialOffer)

let authorizationServerSupportsPar = credentialOffer.authorizationServerMetadata.authorizationServerSupportsPar && config.usePAR

let state = StateValue().value

if authorizationServerSupportsPar {
do {
let resource: String? = issuerMetadata.authorizationServers.map { _ in
credentialOffer.credentialIssuerIdentifier.url.absoluteString
}

let result: (
verifier: PKCEVerifier,
code: GetAuthorizationCodeURL,
Expand Down Expand Up @@ -205,14 +209,14 @@ public actor Issuer: IssuerType {
} catch {
return .failure(ValidationError.error(reason: error.localizedDescription))
}

}
}

public func authorizeWithPreAuthorizationCode(
credentialOffer: CredentialOffer,
authorizationCode: IssuanceAuthorization,
clientId: String,
client: Client,
transactionCode: String?,
authorizationDetailsInTokenRequest: AuthorizationDetailsInTokenRequest = .doNotInclude
) async -> Result<AuthorizedRequest, Error> {
Expand All @@ -221,13 +225,13 @@ public actor Issuer: IssuerType {
case .preAuthorizationCode(let authorisation, let txCode):
do {
if let transactionCode, let txCode {
if txCode.length != transactionCode.count {
throw ValidationError.error(reason: "Expected transaction code length is \(txCode.length ?? 0) but code of length \(transactionCode.count) passed")
}

if txCode.inputMode != .numeric {
throw ValidationError.error(reason: "Issuers expects transaction code to be numeric but is not.")
}
if txCode.length != transactionCode.count {
throw ValidationError.error(reason: "Expected transaction code length is \(txCode.length ?? 0) but code of length \(transactionCode.count) passed")
}
if txCode.inputMode != .numeric {
throw ValidationError.error(reason: "Issuers expects transaction code to be numeric but is not.")
}
}

let credConfigIdsAsAuthDetails: [CredentialConfigurationIdentifier] = switch authorizationDetailsInTokenRequest {
Expand All @@ -238,7 +242,7 @@ public actor Issuer: IssuerType {
let response = try await authorizer.requestAccessTokenPreAuthFlow(
preAuthorizedCode: authorisation,
txCode: txCode,
clientId: clientId,
client: client,
transactionCode: transactionCode,
identifiers: credConfigIdsAsAuthDetails,
dpopNonce: nil,
Expand All @@ -254,14 +258,14 @@ public actor Issuer: IssuerType {
.proofRequired(
accessToken: try .init(
accessToken: accessToken.accessToken,
tokenType: accessToken.tokenType,
tokenType: accessToken.tokenType,
expiresIn: expiresIn?.asTimeInterval ?? .zero
),
refreshToken: try .init(
refreshToken: refreshToken.refreshToken
),
cNonce: cNonce,
credentialIdentifiers: identifiers,
credentialIdentifiers: identifiers,
timeStamp: Date().timeIntervalSinceReferenceDate,
dPopNonce: dPopNonce
)
Expand Down Expand Up @@ -627,7 +631,7 @@ private extension Issuer {
credentialConfigurationIdentifier: CredentialConfigurationIdentifier,
responseEncryptionSpecProvider: (_ issuerResponseEncryptionMetadata: CredentialResponseEncryption) -> IssuanceResponseEncryptionSpec?
) async throws -> Result<SubmittedRequest, Error> {

guard let supportedCredential = issuerMetadata
.credentialsSupported[credentialConfigurationIdentifier] else {
throw ValidationError.error(reason: "Invalid Supported credential for requestSingle")
Expand All @@ -647,7 +651,7 @@ private extension Issuer {
) {
return try supportedCredential.toIssuanceRequest(
requester: issuanceRequester,
claimSet: claimSet,
claimSet: claimSet,
proofs: proofs,
responseEncryptionSpecProvider: responseEncryptionSpecProvider
)
Expand Down Expand Up @@ -732,9 +736,9 @@ public extension Issuer {
try Issuer(
authorizationServerMetadata: .oauth(
.init(
authorizationEndpoint: Constants.url,
tokenEndpoint: Constants.url,
pushedAuthorizationRequestEndpoint: Constants.url
authorizationEndpoint: Constants.url,
tokenEndpoint: Constants.url,
pushedAuthorizationRequestEndpoint: Constants.url
)
),
issuerMetadata: .init(
Expand All @@ -744,7 +748,7 @@ public extension Issuer {
deferredRequesterPoster: deferredRequesterPoster
)
}

static func createResponseEncryptionSpec(_ issuerResponseEncryptionMetadata: CredentialResponseEncryption) -> IssuanceResponseEncryptionSpec? {
switch issuerResponseEncryptionMetadata {
case .notRequired:
Expand Down Expand Up @@ -885,3 +889,24 @@ public extension Issuer {
return .success(authorizedRequest)
}
}

internal extension Client {

private static let ATTEST_JWT_CLIENT_AUTH = "attest_jwt_client_auth"

func ensureSupportedByAuthorizationServer(_ authorizationServerMetadata: IdentityAndAccessManagementMetadata) throws {

let tokenEndpointAuthMethods = authorizationServerMetadata.tokenEndpointAuthMethods

switch self {
case .attested:
let expectedMethod = Self.ATTEST_JWT_CLIENT_AUTH

guard tokenEndpointAuthMethods.contains(expectedMethod) else {
throw ValidationError.error(reason:("\(Self.ATTEST_JWT_CLIENT_AUTH) not supported by authorization server"))
}
default:
break
}
}
}
77 changes: 77 additions & 0 deletions Sources/Main/AttestationBasedClient/Client.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2023 European Commission
*
* 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 Foundation
import JOSESwift

public enum Client {

/// Represents a Public client
case `public`(id: ClientId)

/// Represents an Attested client
case attested(attestationJWT: ClientAttestationJWT, popJwtSpec: ClientAttestationPoPJWTSpec)

// Computed property for 'id' (common property for both cases)
public var id: ClientId {
switch self {
case .public(let id):
return id
case .attested(let attestationJWT, _):
return attestationJWT.clientId
}
}

// MARK: - Validation
public init(public id: ClientId) {
self = .public(id: id)
}

public init(attestationJWT: ClientAttestationJWT, popJwtSpec: ClientAttestationPoPJWTSpec) throws {
// Validate clientId
let clientId = attestationJWT.clientId

guard !clientId.isEmpty && !clientId.trimmingCharacters(in: .whitespaces).isEmpty else {
throw ClientAttestationError.invalidClientId
}

// Validate public key
guard (attestationJWT.pubKey?.isPublicKey ?? false) else {
throw ClientAttestationError.missingJwkClaim
}

self = .attested(attestationJWT: attestationJWT, popJwtSpec: popJwtSpec)
}
}

extension JWK {
/// Determines if the JWK is a private key
var isPrivateKey: Bool {
switch self {
case let rsaKey as RSAPrivateKey:
return !rsaKey.privateExponent.isEmpty
case let ecKey as ECPrivateKey:
return !ecKey.privateKey.isEmpty
default:
return false
}
}

/// Determines if the JWK is a public key
var isPublicKey: Bool {
return !isPrivateKey
}
}

Loading