Skip to content

Commit

Permalink
API request usability and stability improvements (#202)
Browse files Browse the repository at this point in the history
* Refinements to APIRequest and `bodyParameters`

This improves how APIRequest arguments are handled, and ensures type safety for the `bodyParameters` arguments. This improves the cleanliness of unit tests, ensures the types can be properly normalized when merging values from different software components, and pushes conversion of API request values down to the lowest level.

* Ensure SDKVersion registration locks

* Fix github pages documentation generation for tagged releases
  • Loading branch information
mikenachbaur-okta authored Jul 23, 2024
1 parent fdef1f8 commit c828fec
Show file tree
Hide file tree
Showing 29 changed files with 149 additions and 114 deletions.
6 changes: 4 additions & 2 deletions .github/workflows/documentation-ghpages.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
push:
branches:
- master
tags:
- '*'

env:
DEVELOPER_DIR: /Applications/Xcode_15.3.app/Contents/Developer
Expand All @@ -26,8 +28,8 @@ jobs:
- name: Build Documentation
run: |
set +ex
VERSION=$(git describe --tags 2>/dev/null)
if [[ $? -ne 0 ]]; then
VERSION=${{ github.ref_name }}
if [[ "$VERSION" = "master" ]]; then
VERSION=development
fi
set -e
Expand Down
4 changes: 2 additions & 2 deletions Sources/AuthFoundation/Network/APIRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ public protocol APIAuthorization {
/// Defines key/value pairs for an ``APIRequest`` body.
public protocol APIRequestBody {
/// Key/value pairs to use when generating an ``APIRequest`` body.
var bodyParameters: [String: Any]? { get }
var bodyParameters: [String: APIRequestArgument]? { get }
}

/// Provides contextual information when parsing and decoding ``APIRequest`` responses, or errors.
Expand Down Expand Up @@ -158,7 +158,7 @@ extension APIParsingContext {

extension APIRequest where Self: APIRequestBody {
public func body() throws -> Data? {
try contentType?.encodedData(with: bodyParameters)
try contentType?.encodedData(with: bodyParameters?.stringComponents)
}
}

Expand Down
18 changes: 18 additions & 0 deletions Sources/AuthFoundation/Network/APIRequestArgument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ public protocol APIRequestArgument {
var stringValue: String { get }
}

extension Dictionary<String, APIRequestArgument> {
public var stringComponents: [String: String] {
mapValues { $0.stringValue }
}
}

extension APIRequestArgument where Self: RawRepresentable, Self.RawValue.Type == String.Type {
public var stringValue: String {
rawValue
}
}

extension String: APIRequestArgument {
public var stringValue: String { self }
}
Expand Down Expand Up @@ -97,3 +109,9 @@ extension NSString: APIRequestArgument {
}

extension NSNumber: APIRequestArgument {}

extension JWT: APIRequestArgument {}

extension GrantType: APIRequestArgument {}

extension Token.Kind: APIRequestArgument {}
40 changes: 28 additions & 12 deletions Sources/AuthFoundation/Network/Internal/String+AuthFoundation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,31 +54,47 @@ private let systemVersion: String = {
#endif
}()

public final class SDKVersion {
/// Utility class that allows SDK components to register their name and version for use in HTTP User-Agent values.
///
/// The Okta Client SDK consists of multiple libraries, each of which may or may not be used within the same application, or at the same time. To allow version information to be sustainably managed, this class can be used to centralize the registration of these SDK versions to report just the components used within an application.
public final class SDKVersion: Sendable {
/// The name of this library component.
public let name: String

/// The version number string of this library component.
public let version: String

public init(sdk name: String, version: String) {
self.name = name
self.version = version
}


/// The formatted display name for this individual library's information.
public var displayName: String { "\(name)/\(version)" }

/// The calculated user agent string that will be included in outgoing network requests.
public private(set) static var userAgent: String = ""

private static let lock = UnfairLock()
fileprivate static var sdkVersions: [SDKVersion] = []

/// Register a new SDK library component to be added to the ``userAgent`` value.
/// > Note: SDK ``name`` values must be unique. If a duplicate SDK version is already added, only the first registered SDK value will be applied.
/// - Parameter sdk: SDK version to add.
public static func register(sdk: SDKVersion) {
guard sdkVersions.filter({ $0.name == sdk.name }).isEmpty else {
return
lock.withLock {
guard sdkVersions.filter({ $0.name == sdk.name }).isEmpty else {
return
}

sdkVersions.append(sdk)

let sdkVersions = SDKVersion.sdkVersions
.sorted(by: { $0.name < $1.name })
.map(\.displayName)
.joined(separator: " ")
userAgent = "\(sdkVersions) \(systemName)/\(systemVersion) Device/\(deviceModel)"
}

sdkVersions.append(sdk)

let sdkVersions = SDKVersion.sdkVersions
.sorted(by: { $0.name < $1.name })
.map(\.displayName)
.joined(separator: " ")
userAgent = "\(sdkVersions) \(systemName)/\(systemVersion) Device/\(deviceModel)"
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/AuthFoundation/OAuth2/ClientAuthentication.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ extension OAuth2Client {
case clientSecret(String)

/// The additional parameters this authentication type will contribute to outgoing API requests when needed.
public var additionalParameters: [String: String]? {
public var additionalParameters: [String: APIRequestArgument]? {
switch self {
case .none:
return nil
Expand Down
16 changes: 11 additions & 5 deletions Sources/AuthFoundation/OAuth2/OAuth2TokenRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,21 @@ import Foundation
public protocol OAuth2TokenRequest: APIRequest, APIRequestBody where ResponseType == Token {
/// The application's OAuth2 `client_id` value for this token request.
var clientId: String { get }

/// The client's Open ID Configuration object defining the settings and endpoints used to interact with this Authorization Server.
var openIdConfiguration: OpenIdConfiguration { get }
}

extension OAuth2TokenRequest {
private var bodyParameterStrings: [String: String]? {
bodyParameters?.compactMapValues({ $0 as? String })
}

/// Convenience function that exposes an array of ACR Values requested by this OAuth2 token request, if applicable.
public var acrValues: [String]? {
bodyParameterStrings?["acr_values"]?.components(separatedBy: .whitespaces)
bodyParameters?
.stringComponents["acr_values"]?
.components(separatedBy: .whitespaces)
}

public var url: URL { openIdConfiguration.tokenEndpoint }
public var httpMethod: APIRequestMethod { .post }
public var contentType: APIContentType? { .formEncoded }
public var acceptsType: APIContentType? { .json }
}
22 changes: 11 additions & 11 deletions Sources/AuthFoundation/Requests/Token+Requests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ extension Token {
let url: URL
let token: String
let hint: Token.Kind?
let configuration: [String: String]
let configuration: [String: APIRequestArgument]

init(openIdConfiguration: OpenIdConfiguration,
clientAuthentication: OAuth2Client.ClientAuthentication,
token: String,
hint: Token.Kind?,
configuration: [String: String]) throws
configuration: [String: APIRequestArgument]) throws
{
self.openIdConfiguration = openIdConfiguration
self.clientAuthentication = clientAuthentication
Expand All @@ -44,7 +44,7 @@ extension Token {
let clientConfiguration: OAuth2Client.Configuration
let refreshToken: String
let id: String
let configuration: [String: String]
let configuration: [String: APIRequestArgument]

static let placeholderId = "temporary_id"
}
Expand Down Expand Up @@ -90,12 +90,12 @@ extension Token.RevokeRequest: OAuth2APIRequest, APIRequestBody {
var httpMethod: APIRequestMethod { .post }
var contentType: APIContentType? { .formEncoded }
var acceptsType: APIContentType? { .json }
var bodyParameters: [String: Any]? {
var bodyParameters: [String: APIRequestArgument]? {
var result = configuration
result["token"] = token

if let hint = hint {
result["token_type_hint"] = hint.rawValue
result["token_type_hint"] = hint
}

if let parameters = clientAuthentication.additionalParameters {
Expand All @@ -113,11 +113,11 @@ extension Token.IntrospectRequest: OAuth2APIRequest, APIRequestBody {
var contentType: APIContentType? { .formEncoded }
var acceptsType: APIContentType? { .json }
var authorization: APIAuthorization? { nil }
var bodyParameters: [String: Any]? {
var result = [
"token": (token.token(of: type) ?? "") as String,
var bodyParameters: [String: APIRequestArgument]? {
var result: [String: APIRequestArgument] = [
"token": token.token(of: type) ?? "",
"client_id": token.context.configuration.clientId,
"token_type_hint": type.rawValue
"token_type_hint": type
]

if let parameters = clientConfiguration.authentication.additionalParameters {
Expand All @@ -136,8 +136,8 @@ extension Token.RefreshRequest: OAuth2APIRequest, APIRequestBody, APIParsingCont
var contentType: APIContentType? { .formEncoded }
var acceptsType: APIContentType? { .json }
var clientId: String { clientConfiguration.clientId }
var bodyParameters: [String: Any]? {
var result = configuration
var bodyParameters: [String: APIRequestArgument]? {
var result: [String: APIRequestArgument] = configuration
result["grant_type"] = "refresh_token"
result["refresh_token"] = refreshToken

Expand Down
1 change: 1 addition & 0 deletions Sources/AuthFoundation/Responses/GrantType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import Foundation

/// An enumeration used to define a grant type, which defines the methods an application can use to gain access tokens from an authorization server.
public enum GrantType: Codable, Hashable, IsClaim {
case authorizationCode
case implicit
Expand Down
2 changes: 1 addition & 1 deletion Sources/OktaDirectAuth/DirectAuthFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ public class DirectAuthenticationFlow: AuthenticationFlow {
}

/// Channel used when authenticating an out-of-band factor using Okta Verify.
public enum OOBChannel: String, Codable {
public enum OOBChannel: String, Codable, APIRequestArgument {
/// Utilize Okta Verify Push notifications to authenticate the user.
case push

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import AuthFoundation
/// Defines the additional token parameters that can be introduced through input arguments.
protocol HasTokenParameters {
/// Parameters to include in the API request.
func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: String]
func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: APIRequestArgument]
}

/// Defines the common properties and functions shared between factor types.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ extension DirectAuthenticationFlow.ContinuationFactor: AuthenticationFactor {
}

extension DirectAuthenticationFlow.ContinuationFactor: HasTokenParameters {
func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: String] {
var result: [String: String] = [
"grant_type": grantType(currentStatus: currentStatus).rawValue,
func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: APIRequestArgument] {
var result: [String: APIRequestArgument] = [
"grant_type": grantType(currentStatus: currentStatus),
]

if let context = currentStatus?.mfaContext {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ extension DirectAuthenticationFlow.PrimaryFactor: AuthenticationFactor {
}

extension DirectAuthenticationFlow.PrimaryFactor: HasTokenParameters {
func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: String] {
var result: [String: String] = [
"grant_type": grantType(currentStatus: currentStatus).rawValue,
func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: APIRequestArgument] {
var result: [String: APIRequestArgument] = [
"grant_type": grantType(currentStatus: currentStatus),
]

switch self {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ extension DirectAuthenticationFlow.SecondaryFactor: AuthenticationFactor {
}

extension DirectAuthenticationFlow.SecondaryFactor: HasTokenParameters {
func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: String] {
var result: [String: String] = [
"grant_type": grantType(currentStatus: currentStatus).rawValue,
func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: APIRequestArgument] {
var result: [String: APIRequestArgument] = [
"grant_type": grantType(currentStatus: currentStatus),
]

if let context = currentStatus?.mfaContext {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ extension ChallengeRequest: APIRequest, APIRequestBody {
var httpMethod: APIRequestMethod { .post }
var contentType: APIContentType? { .formEncoded }
var acceptsType: APIContentType? { .json }
var bodyParameters: [String: Any]? {
var result: [String: Any] = [
var bodyParameters: [String: APIRequestArgument]? {
var result: [String: APIRequestArgument] = [
"client_id": clientConfiguration.clientId,
"mfa_token": mfaToken,
"challenge_types_supported": challengeTypesSupported
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ struct OOBResponse: Codable, HasTokenParameters {
self.bindingCode = bindingCode
}

func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: String] {
func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: APIRequestArgument] {
["oob_code": oobCode]
}
}
Expand Down Expand Up @@ -78,12 +78,12 @@ extension OOBAuthenticateRequest: APIRequest, APIRequestBody {
var httpMethod: APIRequestMethod { .post }
var contentType: APIContentType? { .formEncoded }
var acceptsType: APIContentType? { .json }
var bodyParameters: [String: Any]? {
var result: [String: Any] = [
var bodyParameters: [String: APIRequestArgument]? {
var result: [String: APIRequestArgument] = [
"client_id": clientConfiguration.clientId,
"login_hint": loginHint,
"channel_hint": channelHint.rawValue,
"challenge_hint": challengeHint.rawValue,
"channel_hint": channelHint,
"challenge_hint": challengeHint,
]

if let parameters = clientConfiguration.authentication.additionalParameters {
Expand Down
6 changes: 1 addition & 5 deletions Sources/OktaDirectAuth/Internal/Requests/TokenRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,7 @@ struct TokenRequest {

extension TokenRequest: OAuth2TokenRequest, OAuth2APIRequest, APIRequestBody {
var clientId: String { clientConfiguration.clientId }
var httpMethod: APIRequestMethod { .post }
var url: URL { openIdConfiguration.tokenEndpoint }
var contentType: APIContentType? { .formEncoded }
var acceptsType: APIContentType? { .json }
var bodyParameters: [String: Any]? {
var bodyParameters: [String: APIRequestArgument]? {
var result = factor.tokenParameters(currentStatus: currentStatus)
result["client_id"] = clientConfiguration.clientId
result["scope"] = clientConfiguration.scopes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ extension WebAuthnChallengeRequest: APIRequest, APIRequestBody {
var httpMethod: APIRequestMethod { .post }
var contentType: APIContentType? { .formEncoded }
var acceptsType: APIContentType? { .json }
var bodyParameters: [String: Any]? {
var result: [String: Any] = [
var bodyParameters: [String: APIRequestArgument]? {
var result: [String: APIRequestArgument] = [
"client_id": clientConfiguration.clientId,
"challenge_hint": GrantType.webAuthn.rawValue
"challenge_hint": GrantType.webAuthn
]

if let loginHint = loginHint {
Expand All @@ -64,7 +64,7 @@ extension WebAuthnChallengeRequest: APIRequest, APIRequestBody {
}

extension WebAuthn.AuthenticatorAssertionResponse: HasTokenParameters {
func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: String] {
func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: APIRequestArgument] {
var result = [
"clientDataJSON": clientDataJSON,
"authenticatorData": authenticatorData,
Expand Down
3 changes: 1 addition & 2 deletions Sources/OktaOAuth2/Authentication/ResourceOwnerFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ import AuthFoundation
///
/// This simple authentication flow permits a suer to authenticate using a simple username and password. As such, the configuration is straightforward.
///
/// > Important: Resource Owner authentication does not support MFA or other more secure authentication models, and is not recommended for production applications.
@available(*, deprecated, message: "Please use the DirectAuth SDK's DirectAuthenticationFlow class instead")
/// > Important: Resource Owner authentication does not support MFA or other more secure authentication models, and is not recommended for production applications. Please use the DirectAuth SDK's DirectAuthenticationFlow class instead.
public class ResourceOwnerFlow: AuthenticationFlow {
/// The OAuth2Client this authentication flow will use.
public let client: OAuth2Client
Expand Down
Loading

0 comments on commit c828fec

Please sign in to comment.