From c41191aa8114120b604b680ea6df3b59a87aa073 Mon Sep 17 00:00:00 2001 From: Alex Nachbaur <74688448+mikenachbaur-okta@users.noreply.github.com> Date: Mon, 12 Aug 2024 08:52:03 -0700 Subject: [PATCH] Extend Token with `HasClaims` for custom claims (#204) The IETF RFCs for Tokens not only include a couple extra properties that were not previously addressed, but it states that the responses may be extended with custom information. Authorization servers, such as Keycloak, includes metadata in the token response indicating refresh token expiration, or other data. Since Token responses can be extended, it stands to reason that these objects should conform to `HasClaims` just like other models, to allow for them to be adapted to different authentication scenarios. This update further extends the support for Claims to resolve edge-cases in how convertible values were handled, unifying support around mapping claims to scalar values, arrays, or dictionaries, and cleaning up how JSON objects are mapped. Finally, since the result of refresh operations often doesn't include data provided in the initial token exchange request (such as `device_secret`), the process for merging tokens during refresh operations has been moved to a protocol. This is not yet public, but this may be exposed in the future. --- .../AuthFoundation.docc/AuthFoundation.md | 42 ++-- .../AuthFoundation.docc/WorkingWithClaims.md | 25 +++ .../AuthFoundation/JWT/Enums/JWTClaim.swift | 9 +- .../Extensions/Claim+ValueExtensions.swift | 165 ++++++++++++++ .../ClaimConvertable+Extensions.swift | 54 +++-- .../JWT/Extensions/JWK+EnumExtensions.swift | 2 +- ...nsions.swift => JWTClaim+Extensions.swift} | 0 .../JWT/Internal/Claim+Internal.swift | 44 ++++ .../Internal/DefaultTokenHashValidator.swift | 2 +- .../AuthFoundation/JWT/Protocols/Claim.swift | 80 +------ .../JWT/Protocols/ClaimContainer.swift | 16 +- .../JWT/Protocols/ClaimConvertable.swift | 13 +- .../JWT/Protocols/ClaimError.swift | 33 +++ .../Migrators/OIDCLegacyMigrator.swift | 22 +- .../Network/APIRequestArgument.swift | 21 ++ .../Network/URLSessionProtocol.swift | 1 + .../AuthFoundation/OAuth2/OAuth2Client.swift | 21 +- .../Resources/en.lproj/AuthFoundation.strings | 3 + .../Responses/OpenIdConfiguration.swift | 38 ++-- .../Responses/OpenIdProviderMetadata.swift | 92 ++++---- .../AuthFoundation/Responses/TokenInfo.swift | 5 +- .../AuthFoundation/Responses/UserInfo.swift | 5 +- .../DefaultTokenExchangeCoordinator.swift | 19 ++ .../Internal/Token+Internal.swift | 33 +-- .../Token Management/Token+Context.swift | 12 + .../Token+Initialization.swift | 150 +++++++++++++ .../Token Management/Token.swift | 154 +++++++------ .../TokenExchangeCoordinator.swift | 25 +++ .../Credential+Extensions.swift | 3 + .../Utilities/Data+Extensions.swift | 2 + .../Utilities/Dictionary+Extensions.swift | 2 + .../Utilities/JSONDecodable.swift | 5 + .../AuthFoundation/Utilities/JSONValue.swift | 211 ++++++++++++++---- Sources/AuthFoundation/Version.swift | 1 + Sources/OktaDirectAuth/Version.swift | 1 + .../PublicKeyCredentialRequestOptions.swift | 4 +- Sources/OktaOAuth2/Version.swift | 1 + Sources/WebAuthenticationUI/Version.swift | 1 + Tests/AuthFoundationTests/ClaimTests.swift | 36 ++- .../CredentialCoordinatorTests.swift | 26 +-- .../CredentialRevokeTests.swift | 26 +-- .../DefaultCredentialDataSourceTests.swift | 22 +- .../AuthFoundationTests/JSONValueTests.swift | 83 ++++--- .../KeychainTokenStorageTests.swift | 2 +- .../MockResponses/token-mfa_attestation.json | 2 +- .../MockResponses/token-no_access_token.json | 7 + .../OAuth2ClientTests.swift | 44 ++-- Tests/AuthFoundationTests/TokenTests.swift | 88 +++++++- .../UserDefaultsTokenStorageTests.swift | 54 ++--- Tests/TestCommon/MockToken.swift | 48 ++-- 50 files changed, 1244 insertions(+), 511 deletions(-) create mode 100644 Sources/AuthFoundation/JWT/Extensions/Claim+ValueExtensions.swift rename Sources/AuthFoundation/JWT/Extensions/{Claim+Extensions.swift => JWTClaim+Extensions.swift} (100%) create mode 100644 Sources/AuthFoundation/JWT/Internal/Claim+Internal.swift create mode 100644 Sources/AuthFoundation/JWT/Protocols/ClaimError.swift create mode 100644 Sources/AuthFoundation/Token Management/Internal/DefaultTokenExchangeCoordinator.swift create mode 100644 Sources/AuthFoundation/Token Management/Token+Initialization.swift create mode 100644 Sources/AuthFoundation/Token Management/TokenExchangeCoordinator.swift create mode 100644 Tests/AuthFoundationTests/MockResponses/token-no_access_token.json diff --git a/Sources/AuthFoundation/AuthFoundation.docc/AuthFoundation.md b/Sources/AuthFoundation/AuthFoundation.docc/AuthFoundation.md index 626fb89d1..e60b7ce15 100644 --- a/Sources/AuthFoundation/AuthFoundation.docc/AuthFoundation.md +++ b/Sources/AuthFoundation/AuthFoundation.docc/AuthFoundation.md @@ -39,12 +39,16 @@ You can use AuthFoundation when you want to: ### JWT and Token Verification +- - ``JWT`` - ``JWK`` - ``JWKS`` - ``JWTClaim`` - ``HasClaims`` +- ``Claim`` - ``JSONClaimContainer`` +- ``JSON`` +- ``AnyJSON`` - ``ClaimConvertable`` - ``IsClaim`` - ``Expires`` @@ -71,48 +75,48 @@ You can use AuthFoundation when you want to: ### Networking -- ``APIClient`` -- ``APIClientDelegate`` +- ``APIAuthorization`` - ``APIClientConfiguration`` +- ``APIClientDelegate`` +- ``APIClient`` - ``APIContentType`` -- ``APIRequest`` -- ``APIRequestBody`` +- ``APIParsingContext`` +- ``APIRateLimit`` - ``APIRequestArgument`` +- ``APIRequestBody`` - ``APIRequestMethod`` -- ``APIResponse`` +- ``APIRequest`` - ``APIResponseResult`` -- ``APIRateLimit`` +- ``APIResponse`` - ``APIRetry`` -- ``APIAuthorization`` -- ``APIParsingContext`` -- ``OAuth2APIRequest`` -- ``JSONDecodable`` - ``Empty`` +- ``JSONDecodable`` +- ``OAuth2APIRequest`` ### Error Types - ``APIClientError`` +- ``AuthenticationError`` +- ``ClaimError`` +- ``CredentialError`` +- ``JSONError`` +- ``JWTError`` +- ``KeychainError`` - ``OAuth2Error`` - ``OAuth2ServerError`` - ``OktaAPIError`` -- ``CredentialError`` - ``TokenError`` -- ``JWTError`` -- ``KeychainError`` -- ``AuthenticationError`` -- ``JSONValueError`` ### Migration and versioning - ``SDKVersion`` - ``SDKVersionMigrator`` -- ``Version`` ### Internals and mocking - ``DelegateCollection`` -- ``UsesDelegateCollection`` -- ``URLSessionProtocol`` - ``URLSessionDataTaskProtocol`` -- ``Weak`` +- ``URLSessionProtocol`` +- ``UsesDelegateCollection`` - ``WeakCollection`` +- ``Weak`` diff --git a/Sources/AuthFoundation/AuthFoundation.docc/WorkingWithClaims.md b/Sources/AuthFoundation/AuthFoundation.docc/WorkingWithClaims.md index 9f5ae3d73..2ae73bfd2 100644 --- a/Sources/AuthFoundation/AuthFoundation.docc/WorkingWithClaims.md +++ b/Sources/AuthFoundation/AuthFoundation.docc/WorkingWithClaims.md @@ -15,6 +15,7 @@ A variety of types contain claims, which are identified by types that conform to Some of these types include: * ``JWT`` +* ``Token`` * ``UserInfo`` * ``TokenInfo`` * ``OpenIdConfiguration`` @@ -50,6 +51,30 @@ if let identifier = token.idToken?[.subject] { When working with claims, the type of enum is defined by the conforming type, which can help give you an insight into the possible options available to you. +### Value conversion functions + +Many types that conform to ``ClaimConvertable`` can transform values from a claims payload to a convenient type, but using keyed subscripting will always return an optional value. If you want to ensure that the claim you're retrieving exists, and is of the proper type, you can use the `value` functions available within the ``HasClaims`` protocol. + +These functions include both throwing and optional variations, and are used from within the subscript convenience functions. + +For example: + +``` +if let registrationUrl: URL = try openIdConfiguration.value(for: .registrationEndpoint) { + // Use the value +} +``` + +This works for dictionaries and array values as well. + +``` +let scopes: [String] = try openIdConfiguration.value(for: .scopesSupported) + + +// Or +let profile: [String: String] = try idToken.value(for: "custom_claim") +``` + ### Convenience properties Finally, some common claims which are best represented as more concrete types, such as URL or Date, are provided to simplify your workflow. For example, if you want to retrieve the date the user was authenticated using ``HasClaims/authTime``, or the user's locale (in an Apple-friendly format) using ``HasClaims/userLocale``. diff --git a/Sources/AuthFoundation/JWT/Enums/JWTClaim.swift b/Sources/AuthFoundation/JWT/Enums/JWTClaim.swift index 0cabfca55..51572c766 100644 --- a/Sources/AuthFoundation/JWT/Enums/JWTClaim.swift +++ b/Sources/AuthFoundation/JWT/Enums/JWTClaim.swift @@ -355,7 +355,10 @@ public extension HasClaims where ClaimType == JWTClaim { } /// Returns the Authentication Context Class Reference for this token. - var authenticationClass: String? { self[.authContextClassReference] } + var authenticationClassReference: [String]? { + let value: String? = value(for: .authContextClassReference) + return value?.components(separatedBy: .whitespaces) + } /// The list of authentication methods included in this token, which defines the list of methods that were used to authenticate the user. /// @@ -364,5 +367,7 @@ public extension HasClaims where ClaimType == JWTClaim { /// // The user authenticated with an MFA factor. /// } /// ``` - var authenticationMethods: [AuthenticationMethod]? { arrayValue(AuthenticationMethod.self, for: .authMethodsReference) } + var authenticationMethods: [AuthenticationMethod]? { + value(for: .authMethodsReference) + } } diff --git a/Sources/AuthFoundation/JWT/Extensions/Claim+ValueExtensions.swift b/Sources/AuthFoundation/JWT/Extensions/Claim+ValueExtensions.swift new file mode 100644 index 000000000..8da6ecfc9 --- /dev/null +++ b/Sources/AuthFoundation/JWT/Extensions/Claim+ValueExtensions.swift @@ -0,0 +1,165 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (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 + +/// Extension which introduces single-value conversion functions for ``ClaimConvertable`` types. +public extension HasClaims { + /// Retrieve the value using a payload key, converting to the requested ``ClaimConvertable`` type. + /// - Parameters: + /// - key: String payload key name. + /// - Returns: Value converted to the requested type. + func value(for key: String) throws -> T { + guard let value = T.convert(from: payload[key]) + else { + throw ClaimError.missingRequiredValue(key: key) + } + return value + } + + /// Retrieve the value using a claim enum, converting to the requested ``ClaimConvertable`` type. + /// - Parameters: + /// - claim: Claim enum value. + /// - Returns: Value converted to the requested type. + func value(for claim: ClaimType) throws -> T { + try value(for: claim.rawValue) + } + + /// Retrieve the optional value using a payload key, converting to the requested ``ClaimConvertable`` type. + /// - Parameters: + /// - key: String payload key name. + /// - Returns: Optional value converted to the requested type. + func value(for key: String) -> T? { + T.convert(from: payload[key]) + } + + /// Retrieve the optional value using a claim enum, converting to the requested ``ClaimConvertable`` type. + /// - Parameters: + /// - claim: Claim enum value. + /// - Returns: Optional value converted to the requested type. + func value(for claim: ClaimType) -> T? { + value(for: claim.rawValue) + } +} + +/// Extension which introduces array-value conversion functions for ``ClaimConvertable`` types. +public extension HasClaims { + /// Returns the value for the given key as an array of values converted using a``ClaimConvertable`` type. + /// - Parameter key: String payload key name. + /// - Returns: Value converted to an array of the requested type. + func value(for key: String) throws -> [T] { + guard let array = payload[key] as? [ClaimConvertable] + else { + throw ClaimError.missingRequiredValue(key: key) + } + + return array.compactMap { T.convert(from: $0) } + } + + /// Returns the value for the given key as an array of values converted using a``ClaimConvertable`` type. + /// - Parameter key: String payload key name. + /// - Returns: Value converted to an array of the requested type. + func value(for claim: ClaimType) throws -> [T] { + try value(for: claim.rawValue) + } + + /// Returns the optional value for the given key as an array of values converted using a``ClaimConvertable`` type. + /// - Parameter key: String payload key name. + /// - Returns: Optional value converted to an array of the requested type. + func value(for key: String) -> [T]? { + let array = payload[key] as? [ClaimConvertable] + return array?.compactMap { T.convert(from: $0) } + } + + /// Returns the optional value for the given key as an array of values converted using a``ClaimConvertable`` type. + /// - Parameter key: String payload key name. + /// - Returns: Optional value converted to an array of the requested type. + func value(for claim: ClaimType) -> [T]? { + value(for: claim.rawValue) + } +} + +/// Extension which introduces dictionary-value conversion functions for ``ClaimConvertable`` types. +public extension HasClaims { + /// Returns the value for the given key as an array of values converted using a``ClaimConvertable`` type. + /// - Parameter key: String payload key name. + /// - Returns: Value converted to an array of the requested type. + func value(for key: String) throws -> [String: T] { + guard let dictionary = payload[key] as? [String: ClaimConvertable] + else { + throw ClaimError.missingRequiredValue(key: key) + } + + return dictionary.compactMapValues { T.convert(from: $0) } + } + + /// Returns the value for the given key as an array of values converted using a``ClaimConvertable`` type. + /// - Parameter key: String payload key name. + /// - Returns: Value converted to an array of the requested type. + func value(for claim: ClaimType) throws -> [String: T] { + try value(for: claim.rawValue) + } + + /// Returns the optional value for the given key as an array of values converted using a``ClaimConvertable`` type. + /// - Parameter key: String payload key name. + /// - Returns: Optional value converted to an array of the requested type. + func value(for key: String) -> [String: T]? { + let dictionary = payload[key] as? [String: ClaimConvertable] + return dictionary?.compactMapValues { T.convert(from: $0) } + } + + /// Returns the optional value for the given key as an array of values converted using a``ClaimConvertable`` type. + /// - Parameter key: String payload key name. + /// - Returns: Optional value converted to an array of the requested type. + func value(for claim: ClaimType) -> [String: T]? { + value(for: claim.rawValue) + } +} + +/// Extension which introduces single-value subscript conversion functions for ``ClaimConvertable`` types. +public extension HasClaims { + /// Return the given claim's value, defined with the given enum value, as the expectred ``ClaimConvertable``value type. + subscript(_ claim: ClaimType) -> T? { + value(for: claim) + } + + /// Return the given claim's value, defined with the given enum value, as the expectred ``ClaimConvertable``value type. + subscript(_ key: String) -> T? { + value(for: key) + } +} + +/// Extension which introduces array-value subscript conversion functions for ``ClaimConvertable`` types. +public extension HasClaims { + /// Return the given claim's value, defined with the given enum value, as the expectred ``ClaimConvertable``value type. + subscript(_ claim: ClaimType) -> [T]? { + value(for: claim) + } + + /// Return the given claim's value, defined with the given enum value, as the expectred ``ClaimConvertable``value type. + subscript(_ key: String) -> [T]? { + value(for: key) + } +} + +/// Extension which introduces dictionary-value subscript conversion functions for ``ClaimConvertable`` types. +public extension HasClaims { + /// Return the given claim's value, defined with the given enum value, as the expectred ``ClaimConvertable``value type. + subscript(_ claim: ClaimType) -> [String: T]? { + value(for: claim) + } + + /// Return the given claim's value, defined with the given enum value, as the expectred ``ClaimConvertable``value type. + subscript(_ key: String) -> [String: T]? { + value(for: key) + } +} diff --git a/Sources/AuthFoundation/JWT/Extensions/ClaimConvertable+Extensions.swift b/Sources/AuthFoundation/JWT/Extensions/ClaimConvertable+Extensions.swift index be3b1833a..2c84430a8 100644 --- a/Sources/AuthFoundation/JWT/Extensions/ClaimConvertable+Extensions.swift +++ b/Sources/AuthFoundation/JWT/Extensions/ClaimConvertable+Extensions.swift @@ -12,25 +12,42 @@ import Foundation +@_documentation(visibility: private) extension String: ClaimConvertable {} + +@_documentation(visibility: private) extension Bool: ClaimConvertable {} + +@_documentation(visibility: private) extension Int: ClaimConvertable {} + +@_documentation(visibility: private) extension Double: ClaimConvertable {} + +@_documentation(visibility: private) extension Float: ClaimConvertable {} -extension Array: ClaimConvertable {} -extension Dictionary: ClaimConvertable {} + +@_documentation(visibility: private) extension URL: ClaimConvertable {} + +@_documentation(visibility: private) extension Date: ClaimConvertable {} + +@_documentation(visibility: private) extension JWTClaim: ClaimConvertable {} + +@_documentation(visibility: private) extension GrantType: ClaimConvertable {} + +@_documentation(visibility: private) extension NSString: ClaimConvertable {} + +@_documentation(visibility: private) extension NSNumber: ClaimConvertable {} +@_documentation(visibility: private) extension ClaimConvertable where Self == Date { - public static func claim(_ claim: String, - in type: any HasClaims, - from value: Any?) -> Self? - { + public static func convert(from value: Any?) -> Self? { if let time = value as? Int { return Date(timeIntervalSince1970: TimeInterval(time)) } @@ -43,22 +60,25 @@ extension ClaimConvertable where Self == Date { } } +@_documentation(visibility: private) extension ClaimConvertable where Self == URL { - public static func claim(_ claim: String, - in type: any HasClaims, - from value: Any?) -> Self? - { + public static func convert(from value: Any?) -> Self? { guard let string = value as? String else { return nil } return URL(string: string) } } -extension ClaimConvertable where Self: IsClaim { - public static func claim(_ claim: String, - in type: any HasClaims, - from value: Any?) -> Self? - { - guard let value = value as? String else { return nil } - return .init(rawValue: value) +@_documentation(visibility: private) +extension ClaimConvertable where Self: RawRepresentable { + public static func convert(from value: Any?) -> Self? { + if let value = value as? Self { + return value + } + + if let value = value as? Self.RawValue { + return Self(rawValue: value) + } + + return nil } } diff --git a/Sources/AuthFoundation/JWT/Extensions/JWK+EnumExtensions.swift b/Sources/AuthFoundation/JWT/Extensions/JWK+EnumExtensions.swift index 1d8787ce8..42e1e273b 100644 --- a/Sources/AuthFoundation/JWT/Extensions/JWK+EnumExtensions.swift +++ b/Sources/AuthFoundation/JWT/Extensions/JWK+EnumExtensions.swift @@ -13,7 +13,7 @@ import Foundation // swiftlint:disable cyclomatic_complexity -extension JWK.Algorithm: RawRepresentable, Equatable, Hashable { +extension JWK.Algorithm: RawRepresentable, Equatable, Hashable, ClaimConvertable { public typealias RawValue = String public init?(rawValue: RawValue) { diff --git a/Sources/AuthFoundation/JWT/Extensions/Claim+Extensions.swift b/Sources/AuthFoundation/JWT/Extensions/JWTClaim+Extensions.swift similarity index 100% rename from Sources/AuthFoundation/JWT/Extensions/Claim+Extensions.swift rename to Sources/AuthFoundation/JWT/Extensions/JWTClaim+Extensions.swift diff --git a/Sources/AuthFoundation/JWT/Internal/Claim+Internal.swift b/Sources/AuthFoundation/JWT/Internal/Claim+Internal.swift new file mode 100644 index 000000000..8347f93a6 --- /dev/null +++ b/Sources/AuthFoundation/JWT/Internal/Claim+Internal.swift @@ -0,0 +1,44 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (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 + +/// Internal convenience extensions for mapping values directly from a payload +extension IsClaim { + static func optionalValue(_ claim: Self, in payload: [String: Any]) -> T? { + T.convert(from: payload[claim.rawValue]) + } + + static func optionalValue(_ claim: Self, in payload: [String: Any]) -> [T]? { + let value = payload[claim.rawValue] as? [ClaimConvertable] + return value?.compactMap { T.convert(from: $0) } + } + + static func value(_ claim: Self, in payload: [String: Any]) throws -> T { + guard let value = T.convert(from: payload[claim.rawValue]) else { + throw ClaimError.missingRequiredValue(key: claim.rawValue) + } + return value + } + + static func value(_ claim: Self, in payload: [String: Any]) throws -> [T] { + guard let value = payload[claim.rawValue] as? [ClaimConvertable] else { + throw ClaimError.missingRequiredValue(key: claim.rawValue) + } + return try value.compactMap { value in + guard let value = T.convert(from: value) else { + throw ClaimError.missingRequiredValue(key: claim.rawValue) + } + return value + } + } +} diff --git a/Sources/AuthFoundation/JWT/Internal/DefaultTokenHashValidator.swift b/Sources/AuthFoundation/JWT/Internal/DefaultTokenHashValidator.swift index 36117481f..70215690a 100644 --- a/Sources/AuthFoundation/JWT/Internal/DefaultTokenHashValidator.swift +++ b/Sources/AuthFoundation/JWT/Internal/DefaultTokenHashValidator.swift @@ -30,7 +30,7 @@ struct DefaultTokenHashValidator: TokenHashValidator { } #else func validate(_ string: String, idToken: JWT) throws { - guard let hashKey = idToken.value(String.self, for: hashKey.rawValue) + guard let hashKey: String = idToken.value(for: hashKey.rawValue) else { return } diff --git a/Sources/AuthFoundation/JWT/Protocols/Claim.swift b/Sources/AuthFoundation/JWT/Protocols/Claim.swift index a49784c6e..ded6a06ce 100644 --- a/Sources/AuthFoundation/JWT/Protocols/Claim.swift +++ b/Sources/AuthFoundation/JWT/Protocols/Claim.swift @@ -13,95 +13,35 @@ import Foundation /// Indicates a type that can be used as an enum value for the ``HasClaims/ClaimType`` associated type. -public protocol IsClaim { - var rawValue: String { get } - - init?(rawValue: String) -} +public protocol IsClaim: RawRepresentable {} /// Used by classes that contains OAuth2 claims. /// -/// This provides common conveniences for interacting with user or token information within those claims. For example, iterating through ``allClaims-4c54a`` or using keyed subscripting to access specific claims. +/// This provides common conveniences for interacting with user or token information within those claims. For example, iterating through ``allClaims`` or using keyed subscripting to access specific claims. public protocol HasClaims { associatedtype ClaimType: IsClaim - /// Returns the collection of claims this object contains. - /// - /// > Note: This will only return the list of official claims defined in the ``Claim`` enum. For custom claims, please see the ``customClaims`` property. - var claims: [ClaimType] { get } - - /// Returns the collection of custom claims this object contains. - /// - /// Unlike the ``claims`` property, this returns values as strings. - var customClaims: [String] { get } - /// Raw payload of claims, as a dictionary representation. + /// + /// Types conforming to this protocol must return the raw payload of claim values. The convenience functions used for loading and converting claims are made available through extensions to this protocol. var payload: [String: Any] { get } } public extension HasClaims { - /// The list of standard claims contained within this JWT token. + /// Returns the collection of claims this object contains. + /// + /// > Note: This will only return the list of official claims defined in the ``Claim`` enum. For custom claims, please see the ``customClaims`` property. var claims: [ClaimType] { payload.keys.compactMap { ClaimType(rawValue: $0) } } - /// The list of custom claims contained within this JWT token. + /// Returns the collection of custom claims this object contains. + /// + /// Unlike the ``claims`` property, this returns values as strings. var customClaims: [String] { payload.keys.filter { ClaimType(rawValue: $0) == nil } } - /// Returns a claim value from this JWT token, with the given key and expected return type. - /// - Returns: The value for the supplied claim. - func value(_ type: T.Type, for key: String) -> T? { - T.claim(key, in: self, from: payload[key]) - } - - /// Returns an array of claims from this JWT token, with the given key and expected array element type. - /// - Returns: The value for the supplied claim. - func arrayValue(_ type: T.Type, for key: String) -> [T]? { - guard let array = payload[key] as? [ClaimConvertable] - else { - return nil - } - - return array.compactMap { element in - T.claim(key, in: self, from: element) - } - } - - /// Returns an array of claims from this JWT token, with the given key and expected array element type. - /// - Returns: The value for the supplied claim. - func arrayValue(_ type: T.Type, for claim: ClaimType) -> [T]? { - arrayValue(type, for: claim.rawValue) - } - - /// Return the given claim's Dictionary of ``ClaimConvertable``values. - subscript(_ claim: String) -> [String: T?]? { - guard let dict = payload[claim] as? [String: ClaimConvertable] - else { - return nil - } - - return dict.mapValues { value in - T.claim(claim, in: self, from: value) - } - } - - /// Return the given claim's value, defined with the given enum value, as the expectred ``ClaimConvertable``value type. - subscript(_ claim: ClaimType) -> T? { - self[claim.rawValue] - } - - /// Return the given claim's value, defined with the given enum value, as the expectred ``ClaimConvertable``value type. - subscript(_ claim: String) -> T? { - T.claim(claim, in: self, from: payload[claim]) - } - - /// Return the given claim's value as the expectred ``ClaimConvertable``value type. - subscript(_ claim: String) -> T? { - payload[claim] as? T - } - /// All claims, across both standard ``claims`` and ``customClaims``. var allClaims: [String] { Array([ diff --git a/Sources/AuthFoundation/JWT/Protocols/ClaimContainer.swift b/Sources/AuthFoundation/JWT/Protocols/ClaimContainer.swift index bdf7fd17a..596b2cb4c 100644 --- a/Sources/AuthFoundation/JWT/Protocols/ClaimContainer.swift +++ b/Sources/AuthFoundation/JWT/Protocols/ClaimContainer.swift @@ -18,9 +18,14 @@ import Foundation public protocol JSONClaimContainer: HasClaims, JSONDecodable {} extension JSONClaimContainer { - public func encode(to encoder: Encoder) throws { + static func decodePayload(from decoder: Decoder) throws -> [String: Any] { + let container = try decoder.container(keyedBy: JSONCodingKeys.self) + return try container.decode([String: Any].self) + } + + static func encodePayload(_ object: any HasClaims & Codable, to encoder: Encoder) throws { var container = encoder.container(keyedBy: JSONCodingKeys.self) - try payload + try object.payload .compactMap { (key: String, value: Any) in guard let key = JSONCodingKeys(stringValue: key) else { return nil } return (key, value) @@ -40,3 +45,10 @@ extension JSONClaimContainer { } } } + +@_documentation(visibility: private) +extension JSONClaimContainer where Self: Codable { + public func encode(to encoder: Encoder) throws { + try Self.encodePayload(self, to: encoder) + } +} diff --git a/Sources/AuthFoundation/JWT/Protocols/ClaimConvertable.swift b/Sources/AuthFoundation/JWT/Protocols/ClaimConvertable.swift index e15a0476f..0ca1fedec 100644 --- a/Sources/AuthFoundation/JWT/Protocols/ClaimConvertable.swift +++ b/Sources/AuthFoundation/JWT/Protocols/ClaimConvertable.swift @@ -14,16 +14,15 @@ import Foundation /// Indicates a type can be consumed from a ``HasClaims`` object and converted to the indicated type. public protocol ClaimConvertable { - static func claim(_ claim: String, - in type: any HasClaims, - from value: Any?) -> Self? + /// Converts the given `Any` value to an instance of the conforming type's class, otherwise return `nil` if this cannot be done. + /// - Parameter value: The value to convert. + /// - Returns: The converted value, or `nil`. + static func convert(from value: Any?) -> Self? } extension ClaimConvertable { - public static func claim(_ claim: String, - in type: any HasClaims, - from value: Any?) -> Self? - { + @_documentation(visibility: private) + public static func convert(from value: Any?) -> Self? { value as? Self } } diff --git a/Sources/AuthFoundation/JWT/Protocols/ClaimError.swift b/Sources/AuthFoundation/JWT/Protocols/ClaimError.swift new file mode 100644 index 000000000..5ef59d78c --- /dev/null +++ b/Sources/AuthFoundation/JWT/Protocols/ClaimError.swift @@ -0,0 +1,33 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (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 + +public enum ClaimError: Error { + /// The token response is missing a required value. + case missingRequiredValue(key: String) +} + +extension ClaimError: LocalizedError { + public var errorDescription: String? { + switch self { + case .missingRequiredValue(key: let key): + return String.localizedStringWithFormat( + NSLocalizedString("missing_required_value", + tableName: "AuthFoundation", + bundle: .authFoundation, + comment: ""), + key) + + } + } +} diff --git a/Sources/AuthFoundation/Migration/Migrators/OIDCLegacyMigrator.swift b/Sources/AuthFoundation/Migration/Migrators/OIDCLegacyMigrator.swift index 726001bd3..40f9780fd 100644 --- a/Sources/AuthFoundation/Migration/Migrators/OIDCLegacyMigrator.swift +++ b/Sources/AuthFoundation/Migration/Migrators/OIDCLegacyMigrator.swift @@ -179,17 +179,17 @@ extension SDKVersion.Migration { let issueDate = idToken?.issuedAt ?? Date() - let token = Token(id: item.account, - issuedAt: issueDate, - tokenType: tokenType, - expiresIn: expiresIn, - accessToken: accessToken, - scope: scope, - refreshToken: tokenResponse.refreshToken, - idToken: idToken, - deviceSecret: nil, - context: Token.Context(configuration: configuration, - clientSettings: clientSettings)) + let token = try Token(id: item.account, + issuedAt: issueDate, + tokenType: tokenType, + expiresIn: expiresIn, + accessToken: accessToken, + scope: scope, + refreshToken: tokenResponse.refreshToken, + idToken: idToken, + deviceSecret: nil, + context: Token.Context(configuration: configuration, + clientSettings: clientSettings)) var security = Credential.Security.standard if let accessibility = model?.accessibility, diff --git a/Sources/AuthFoundation/Network/APIRequestArgument.swift b/Sources/AuthFoundation/Network/APIRequestArgument.swift index 0b8ff2c2c..6e3257464 100644 --- a/Sources/AuthFoundation/Network/APIRequestArgument.swift +++ b/Sources/AuthFoundation/Network/APIRequestArgument.swift @@ -37,81 +37,102 @@ public protocol APIRequestArgument { } extension Dictionary { + @_documentation(visibility: private) public var stringComponents: [String: String] { mapValues { $0.stringValue } } } extension APIRequestArgument where Self: RawRepresentable, Self.RawValue.Type == String.Type { + @_documentation(visibility: private) public var stringValue: String { rawValue } } extension String: APIRequestArgument { + @_documentation(visibility: private) public var stringValue: String { self } } extension Int: APIRequestArgument { + @_documentation(visibility: private) public var stringValue: String { "\(self)" } } extension Double: APIRequestArgument { + @_documentation(visibility: private) public var stringValue: String { "\(self)" } } extension Bool: APIRequestArgument { + @_documentation(visibility: private) public var stringValue: String { "\(self)" } } extension UInt: APIRequestArgument { + @_documentation(visibility: private) public var stringValue: String { "\(self)" } } extension Int8: APIRequestArgument { + @_documentation(visibility: private) public var stringValue: String { "\(self)" } } extension UInt8: APIRequestArgument { + @_documentation(visibility: private) public var stringValue: String { "\(self)" } } extension Int16: APIRequestArgument { + @_documentation(visibility: private) public var stringValue: String { "\(self)" } } extension UInt16: APIRequestArgument { + @_documentation(visibility: private) public var stringValue: String { "\(self)" } } extension Int32: APIRequestArgument { + @_documentation(visibility: private) public var stringValue: String { "\(self)" } } extension UInt32: APIRequestArgument { + @_documentation(visibility: private) public var stringValue: String { "\(self)" } } extension Int64: APIRequestArgument { + @_documentation(visibility: private) public var stringValue: String { "\(self)" } } extension UInt64: APIRequestArgument { + @_documentation(visibility: private) public var stringValue: String { "\(self)" } } extension Float: APIRequestArgument { + @_documentation(visibility: private) public var stringValue: String { "\(self)" } } extension NSString: APIRequestArgument { + @_documentation(visibility: private) public var stringValue: String { "\(self)" } } +@_documentation(visibility: private) extension NSNumber: APIRequestArgument {} +@_documentation(visibility: private) extension JWT: APIRequestArgument {} +@_documentation(visibility: private) extension GrantType: APIRequestArgument {} +@_documentation(visibility: private) extension Token.Kind: APIRequestArgument {} diff --git a/Sources/AuthFoundation/Network/URLSessionProtocol.swift b/Sources/AuthFoundation/Network/URLSessionProtocol.swift index 7316d008c..984295efb 100644 --- a/Sources/AuthFoundation/Network/URLSessionProtocol.swift +++ b/Sources/AuthFoundation/Network/URLSessionProtocol.swift @@ -29,6 +29,7 @@ public protocol URLSessionDataTaskProtocol { } extension URLSession: URLSessionProtocol { + @_documentation(visibility: internal) public func dataTaskWithRequest(_ request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTaskProtocol { dataTask(with: request, completionHandler: completionHandler) } diff --git a/Sources/AuthFoundation/OAuth2/OAuth2Client.swift b/Sources/AuthFoundation/OAuth2/OAuth2Client.swift index cb71af6bc..eb2c9a0b1 100644 --- a/Sources/AuthFoundation/OAuth2/OAuth2Client.swift +++ b/Sources/AuthFoundation/OAuth2/OAuth2Client.swift @@ -209,12 +209,21 @@ public final class OAuth2Client { switch result { case .success(let response): - let newToken = response.result.token(merging: token) - - self.delegateCollection.invoke { $0.oauth(client: self, didRefresh: token, replacedWith: newToken) } - NotificationCenter.default.post(name: .tokenRefreshed, object: newToken) - action.finish(.success(newToken)) - + do { + let newToken = try response.result.token(merging: token) + + self.delegateCollection.invoke { $0.oauth(client: self, didRefresh: token, replacedWith: newToken) } + NotificationCenter.default.post(name: .tokenRefreshed, object: newToken) + action.finish(.success(newToken)) + } catch { + self.delegateCollection.invoke { $0.oauth(client: self, didRefresh: token, replacedWith: nil) } + + NotificationCenter.default.post(name: .tokenRefreshFailed, + object: token, + userInfo: ["error": error]) + + action.finish(.failure(.error(error))) + } case .failure(let error): self.delegateCollection.invoke { $0.oauth(client: self, didRefresh: token, replacedWith: nil) } diff --git a/Sources/AuthFoundation/Resources/en.lproj/AuthFoundation.strings b/Sources/AuthFoundation/Resources/en.lproj/AuthFoundation.strings index 48eedb160..282a7f74a 100644 --- a/Sources/AuthFoundation/Resources/en.lproj/AuthFoundation.strings +++ b/Sources/AuthFoundation/Resources/en.lproj/AuthFoundation.strings @@ -74,6 +74,9 @@ "duplicate_token_added" = "Could not add a new token, since a duplicate was found."; "invalid_configuration" = "This token does not match the client configuration."; +/* ClaimError */ +"missing_required_value" = "The token response is missing a required value for key \"%@\"."; + /* CredentialError */ "credential_metadata_consistency" = "Metadata associated with a credential has become inconsistent with its value in storage."; "credential_incorrect_configuration" = "The credential's token does not match the client configuration supplied."; diff --git a/Sources/AuthFoundation/Responses/OpenIdConfiguration.swift b/Sources/AuthFoundation/Responses/OpenIdConfiguration.swift index ad8b85370..8bfe9975e 100644 --- a/Sources/AuthFoundation/Responses/OpenIdConfiguration.swift +++ b/Sources/AuthFoundation/Responses/OpenIdConfiguration.swift @@ -19,20 +19,22 @@ public struct OpenIdConfiguration: Codable, JSONClaimContainer { public typealias ClaimType = ProviderMetadata /// The raw payload of provider metadata claims returned from the OpenID Provider. - public let payload: [String: Any] - - public init(from decoder: Decoder) throws { - let required = try decoder.container(keyedBy: RequiredCodingKeys.self) - issuer = try required.decode(URL.self, forKey: .issuer) - authorizationEndpoint = try required.decode(URL.self, forKey: .authorizationEndpoint) - tokenEndpoint = try required.decode(URL.self, forKey: .tokenEndpoint) - jwksUri = try required.decode(URL.self, forKey: .jwksUri) - responseTypesSupported = try required.decode([String].self, forKey: .responseTypesSupported) - subjectTypesSupported = try required.decode([String].self, forKey: .subjectTypesSupported) - idTokenSigningAlgValuesSupported = try required.decode([JWK.Algorithm].self, forKey: .idTokenSigningAlgValuesSupported) + public var payload: [String: Any] { jsonPayload.jsonValue.anyValue as? [String: Any] ?? [:] } - let container = try decoder.container(keyedBy: JSONCodingKeys.self) - payload = try container.decode([String: Any].self) + let jsonPayload: AnyJSON + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let json = try container.decode(JSON.self) + jsonPayload = .init(json) + + let payload = json.anyValue as? [String: Any] ?? [:] + issuer = try ProviderMetadata.value(.issuer, in: payload) + authorizationEndpoint = try ProviderMetadata.value(.authorizationEndpoint, in: payload) + tokenEndpoint = try ProviderMetadata.value(.tokenEndpoint, in: payload) + jwksUri = try ProviderMetadata.value(.jwksUri, in: payload) + responseTypesSupported = try ProviderMetadata.value(.responseTypesSupported, in: payload) + subjectTypesSupported = try ProviderMetadata.value(.subjectTypesSupported, in: payload) + idTokenSigningAlgValuesSupported = try ProviderMetadata.value(.idTokenSigningAlgValuesSupported, in: payload) } /// The issuer URL for this OpenID provider. @@ -56,12 +58,6 @@ public struct OpenIdConfiguration: Codable, JSONClaimContainer { /// The list of supported ID token signing algorithms for this OpenID Provider. public let idTokenSigningAlgValuesSupported: [JWK.Algorithm] - public static let jsonDecoder: JSONDecoder = { - let result = JSONDecoder() - result.keyDecodingStrategy = .convertFromSnakeCase - return result - }() - enum RequiredCodingKeys: String, CodingKey, CaseIterable { case issuer case authorizationEndpoint @@ -82,6 +78,6 @@ extension OpenIdConfiguration { public var userinfoEndpoint: URL? { self[.userinfoEndpoint] } public var scopesSupported: [String]? { self[.scopesSupported] } public var responseModesSupported: [String]? { self[.responseModesSupported] } - public var claimsSupported: [JWTClaim]? { arrayValue(JWTClaim.self, for: .claimsSupported) } - public var grantTypesSupported: [GrantType]? { arrayValue(GrantType.self, for: .grantTypesSupported) } + public var claimsSupported: [JWTClaim]? { self[.claimsSupported] } + public var grantTypesSupported: [GrantType]? { self[.grantTypesSupported] } } diff --git a/Sources/AuthFoundation/Responses/OpenIdProviderMetadata.swift b/Sources/AuthFoundation/Responses/OpenIdProviderMetadata.swift index 6a45eb31a..1eb119dee 100644 --- a/Sources/AuthFoundation/Responses/OpenIdProviderMetadata.swift +++ b/Sources/AuthFoundation/Responses/OpenIdProviderMetadata.swift @@ -18,52 +18,52 @@ extension OpenIdConfiguration { // Provider claims exposed by the OpenID Provider Metadata specification // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata case issuer - case authorizationEndpoint - case tokenEndpoint - case userinfoEndpoint - case jwksUri - case registrationEndpoint - case scopesSupported - case responseTypesSupported - case responseModesSupported - case grantTypesSupported - case acrValuesSupported - case subjectTypesSupported - case idTokenSigningAlgValuesSupported - case idTokenEncryptionAlgValuesSupported - case idTokenEncryptionEncValuesSupported - case userinfoSigningAlgValuesSupported - case userinfoEncryptionAlgValuesSupported - case userinfoEncryptionEncValuesSupported - case requestObjectSigningAlgValuesSupported - case requestObjectEncryptionAlgValuesSupported - case requestObjectEncryptionEncValuesSupported - case tokenEndpointAuthMethodsSupported - case tokenEndpointAuthSigningAlgValuesSupported - case displayValuesSupported - case claimTypesSupported - case claimsSupported - case serviceDocumentation - case claimsLocalesSupported - case uiLocalesSupported - case claimsParameterSupported - case requestParameterSupported - case requestUriParameterSupported - case requireRequestUriRegistration - case opPolicyUri - case opTosUri - + case authorizationEndpoint = "authorization_endpoint" + case tokenEndpoint = "token_endpoint" + case userinfoEndpoint = "userinfo_endpoint" + case jwksUri = "jwks_uri" + case registrationEndpoint = "registration_endpoint" + case scopesSupported = "scopes_supported" + case responseTypesSupported = "response_types_supported" + case responseModesSupported = "response_modes_supported" + case grantTypesSupported = "grant_types_supported" + case acrValuesSupported = "acr_values_supported" + case subjectTypesSupported = "subject_types_supported" + case idTokenSigningAlgValuesSupported = "id_token_signing_alg_values_supported" + case idTokenEncryptionAlgValuesSupported = "id_token_encryption_alg_values_supported" + case idTokenEncryptionEncValuesSupported = "id_token_encryption_enc_values_supported" + case userinfoSigningAlgValuesSupported = "userinfo_signing_alg_values_supported" + case userinfoEncryptionAlgValuesSupported = "userinfo_encryption_alg_values_supported" + case userinfoEncryptionEncValuesSupported = "userinfo_encryption_enc_values_supported" + case requestObjectSigningAlgValuesSupported = "request_object_signing_alg_values_supported" + case requestObjectEncryptionAlgValuesSupported = "request_object_encryption_alg_values_supported" + case requestObjectEncryptionEncValuesSupported = "request_object_encryption_enc_values_supported" + case tokenEndpointAuthMethodsSupported = "token_endpoint_auth_methods_supported" + case tokenEndpointAuthSigningAlgValuesSupported = "token_endpoint_auth_signing_alg_values_supported" + case displayValuesSupported = "display_values_supported" + case claimTypesSupported = "claim_types_supported" + case claimsSupported = "claims_supported" + case serviceDocumentation = "service_documentation" + case claimsLocalesSupported = "claims_locales_supported" + case uiLocalesSupported = "ui_locales_supported" + case claimsParameterSupported = "claims_parameter_supported" + case requestParameterSupported = "request_parameter_supported" + case requestUriParameterSupported = "request_uri_parameter_supported" + case requireRequestUriRegistration = "require_request_uri_registration" + case opPolicyUri = "op_policy_uri" + case opTosUri = "op_tos_uri" + // Okta-defined additions // https://developer.okta.com/docs/reference/api/oidc/#response-properties-11 - case endSessionEndpoint - case introspectionEndpoint - case deviceAuthorizationEndpoint - case codeChallengeMethodsSupported - case introspectionEndpointAuthMethodsSupported - case revocationEndpoint - case revocationEndpointAuthMethodsSupported - case backchannelTokenDeliveryModesSupported - case backchannelAuthenticationRequestSigningAlgValuesSupported - case dpopSigningAlgValuesSupported - } + case endSessionEndpoint = "end_session_endpoint" + case introspectionEndpoint = "introspection_endpoint" + case deviceAuthorizationEndpoint = "device_authorization_endpoint" + case codeChallengeMethodsSupported = "code_challenge_methods_supported" + case introspectionEndpointAuthMethodsSupported = "introspection_endpoint_auth_methods_supported" + case revocationEndpoint = "revocation_endpoint" + case revocationEndpointAuthMethodsSupported = "revocation_endpoint_auth_methods_supported" + case backchannelTokenDeliveryModesSupported = "backchannel_token_delivery_modes_supported" + case backchannelAuthenticationRequestSigningAlgValuesSupported = "backchannel_authentication_request_signing_alg_values_supported" + case dpopSigningAlgValuesSupported = "dpop_signing_alg_values_supported" + } } diff --git a/Sources/AuthFoundation/Responses/TokenInfo.swift b/Sources/AuthFoundation/Responses/TokenInfo.swift index 8a5e20832..358113002 100644 --- a/Sources/AuthFoundation/Responses/TokenInfo.swift +++ b/Sources/AuthFoundation/Responses/TokenInfo.swift @@ -16,7 +16,7 @@ import Foundation /// /// This provides a convenience mechanism for accessing information related to a token. It supports the ``HasClaims`` protocol, to simplify common operations against introspected information, and to provide consistency with the ``JWT`` class. /// -/// For more information about the members to use, please refer to ``ClaimContainer``. +/// For more information about the members to use, please refer to ``JSONClaimContainer``. public struct TokenInfo: Codable, JSONClaimContainer { public typealias ClaimType = JWTClaim @@ -27,8 +27,7 @@ public struct TokenInfo: Codable, JSONClaimContainer { } public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: JSONCodingKeys.self) - self.init(try container.decode([String: Any].self)) + self.init(try Self.decodePayload(from: decoder)) } /// Indicates if this token is active. diff --git a/Sources/AuthFoundation/Responses/UserInfo.swift b/Sources/AuthFoundation/Responses/UserInfo.swift index 65617800c..add7a225e 100644 --- a/Sources/AuthFoundation/Responses/UserInfo.swift +++ b/Sources/AuthFoundation/Responses/UserInfo.swift @@ -16,7 +16,7 @@ import Foundation /// /// This provides a convenience mechanism for accessing information related to a user. It supports the ``HasClaims`` protocol, to simplify common operations against user information, and to provide consistency with the ``JWT`` class. /// -/// For more information about the members to use, please refer to ``ClaimContainer``. +/// For more information about the members to use, please refer to ``JSONClaimContainer``. public struct UserInfo: Codable, JSONClaimContainer { public typealias ClaimType = JWTClaim @@ -27,8 +27,7 @@ public struct UserInfo: Codable, JSONClaimContainer { } public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: JSONCodingKeys.self) - self.init(try container.decode([String: Any].self)) + self.init(try Self.decodePayload(from: decoder)) } } diff --git a/Sources/AuthFoundation/Token Management/Internal/DefaultTokenExchangeCoordinator.swift b/Sources/AuthFoundation/Token Management/Internal/DefaultTokenExchangeCoordinator.swift new file mode 100644 index 000000000..e7105ed9f --- /dev/null +++ b/Sources/AuthFoundation/Token Management/Internal/DefaultTokenExchangeCoordinator.swift @@ -0,0 +1,19 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (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 + +class DefaultTokenExchangeCoordinator: TokenExchangeCoordinator { + func merge(_ token: Token, payload: [String: Any], with newPayload: [String: Any]) throws -> [String: Any] { + payload.merging(newPayload) { _, new in new } + } +} diff --git a/Sources/AuthFoundation/Token Management/Internal/Token+Internal.swift b/Sources/AuthFoundation/Token Management/Internal/Token+Internal.swift index 05019c638..baa9660d9 100644 --- a/Sources/AuthFoundation/Token Management/Internal/Token+Internal.swift +++ b/Sources/AuthFoundation/Token Management/Internal/Token+Internal.swift @@ -18,22 +18,23 @@ extension Token { /// This function is used to merge these values from an existing token instance to preserve them during a refresh. /// - Parameter token: The old token that has been refreshed. /// - Returns: A new token merging the results of the newly refreshed token, and the older token. - func token(merging token: Token) -> Token { - guard (refreshToken == nil && token.refreshToken != nil) || - (deviceSecret == nil && token.deviceSecret != nil) - else { - return self - } + func token(merging token: Token) throws -> Token { + let newPayload = try Token + .exchangeCoordinator + .merge(token, + payload: token.payload, + with: payload) + + let oldJSON = jsonPayload.jsonValue + let newJSON = try JSON(newPayload) - return Token(id: id, - issuedAt: issuedAt ?? token.issuedAt ?? .nowCoordinated, - tokenType: tokenType, - expiresIn: expiresIn, - accessToken: accessToken, - scope: scope, - refreshToken: refreshToken ?? token.refreshToken, - idToken: idToken, - deviceSecret: deviceSecret ?? token.deviceSecret, - context: context) + if oldJSON != newJSON { + return try Token(id: id, + issuedAt: issuedAt ?? token.issuedAt ?? .nowCoordinated, + context: context, + json: .init(newJSON)) + } + + return self } } diff --git a/Sources/AuthFoundation/Token Management/Token+Context.swift b/Sources/AuthFoundation/Token Management/Token+Context.swift index 682926d60..5f9c89a34 100644 --- a/Sources/AuthFoundation/Token Management/Token+Context.swift +++ b/Sources/AuthFoundation/Token Management/Token+Context.swift @@ -38,5 +38,17 @@ extension Token { self.clientSettings = nil } } + + public init(from decoder: any Decoder) throws { + if let container = try? decoder.container(keyedBy: Token.Context.CodingKeys.self) { + self.init(configuration: try container.decode(OAuth2Client.Configuration.self, forKey: .configuration), + clientSettings: try container.decodeIfPresent([String: String].self, forKey: .clientSettings)) + } else if let configuration = decoder.userInfo[.apiClientConfiguration] as? OAuth2Client.Configuration { + self.init(configuration: configuration, + clientSettings: decoder.userInfo[.clientSettings]) + } else { + throw TokenError.contextMissing + } + } } } diff --git a/Sources/AuthFoundation/Token Management/Token+Initialization.swift b/Sources/AuthFoundation/Token Management/Token+Initialization.swift new file mode 100644 index 000000000..35462101b --- /dev/null +++ b/Sources/AuthFoundation/Token Management/Token+Initialization.swift @@ -0,0 +1,150 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (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 + +extension Token { + public convenience init(from decoder: Decoder) throws { + var id: String = UUID().uuidString + var issuedAt: Date = Date.nowCoordinated + var context: Context? + var json: AnyJSON + + // Initialize defaults supplied from the decoder's userInfo dictionary + if let userInfoId = decoder.userInfo[.tokenId] as? String { + id = userInfoId + } + + if let configuration = decoder.userInfo[.apiClientConfiguration] as? OAuth2Client.Configuration { + context = Context(configuration: configuration, + clientSettings: decoder.userInfo[.clientSettings]) + } + + // Attempt to decode V1 token data + if let container = try? decoder.container(keyedBy: CodingKeysV1.self), + [.id, .accessToken].allSatisfy(container.allKeys.contains) + { + if container.contains(.context) { + context = try container.decode(Context.self, forKey: .context) + } + + if container.contains(.id) { + id = try container.decode(String.self, forKey: .id) + } + + if container.contains(.issuedAt) { + issuedAt = try container.decode(Date.self, forKey: .issuedAt) + } + + var payload: [TokenClaim: Any] = [ + .accessToken: try container.decode(String.self, forKey: .accessToken), + .tokenType: try container.decode(String.self, forKey: .tokenType), + .expiresIn: try container.decode(TimeInterval.self, forKey: .expiresIn), + ] + + if let idToken = try container.decodeIfPresent(String.self, forKey: .idToken) { + payload[.idToken] = idToken + } + + if let scope = try container.decodeIfPresent(String.self, forKey: .scope) { + payload[.scope] = scope + } + + if let refreshToken = try container.decodeIfPresent(String.self, forKey: .refreshToken) { + payload[.refreshToken] = refreshToken + } + + if let deviceSecret = try container.decodeIfPresent(String.self, forKey: .deviceSecret) { + payload[.deviceSecret] = deviceSecret + } + + json = .init(try JSON(payload.reduce(into: [String: Any]()) { result, item in + result[item.key.rawValue] = item.value + })) + } + + // Attempt to decode V2 token data + else if let container = try? decoder.container(keyedBy: CodingKeysV2.self), + container.allKeys.contains(.id) + { + context = try container.decode(Context.self, forKey: .context) + + if container.contains(.id) { + id = try container.decode(String.self, forKey: .id) + } + + if container.contains(.issuedAt) { + issuedAt = try container.decode(Date.self, forKey: .issuedAt) + } + + json = .init(try container.decode(String.self, forKey: .rawValue)) + } + + // Attempt to decode JSON data + else { + let container = try decoder.singleValueContainer() + json = .init(try container.decode(JSON.self)) + } + + guard let context = context else { + throw TokenError.contextMissing + } + + try self.init(id: id, + issuedAt: issuedAt, + context: context, + json: json) + } + + convenience init(id: String, + issuedAt: Date, + tokenType: String, + expiresIn: TimeInterval, + accessToken: String, + scope: String?, + refreshToken: String?, + idToken: JWT?, + deviceSecret: String?, + context: Context) throws + { + var payload: [TokenClaim: Any] = [ + .accessToken: accessToken, + .tokenType: tokenType, + .expiresIn: expiresIn, + ] + + if let idToken = idToken { + payload[.idToken] = idToken.rawValue + } + + if let scope = scope { + payload[.scope] = scope + } + + if let refreshToken = refreshToken { + payload[.refreshToken] = refreshToken + } + + if let deviceSecret = deviceSecret { + payload[.deviceSecret] = deviceSecret + } + + let json = try JSON(payload.reduce(into: [String: Any]()) { result, item in + result[item.key.rawValue] = item.value + }) + + try self.init(id: id, + issuedAt: issuedAt, + context: context, + json: .init(json)) + } +} diff --git a/Sources/AuthFoundation/Token Management/Token.swift b/Sources/AuthFoundation/Token Management/Token.swift index 423618401..97a86c8e7 100644 --- a/Sources/AuthFoundation/Token Management/Token.swift +++ b/Sources/AuthFoundation/Token Management/Token.swift @@ -13,7 +13,9 @@ import Foundation /// Token information representing a user's access to a resource server, including access token, refresh token, and other related information. -public final class Token: Codable, Equatable, Hashable, Expires { +public final class Token: Codable, Equatable, Hashable, JSONClaimContainer, Expires { + public typealias ClaimType = TokenClaim + /// The object used to ensure ID tokens are valid. public static var idTokenValidator: IDTokenValidator = DefaultIDTokenValidator() @@ -23,6 +25,11 @@ public final class Token: Codable, Equatable, Hashable, Expires { /// The object used to ensure device secrets are validated against its associated ID token. public static var deviceSecretValidator: TokenHashValidator = DefaultTokenHashValidator(hashKey: .deviceSecret) + /// Coordinates important operations during token exchange. + /// + /// > Note: This property and interface is currently marked as internal, but may be exposed publicly in the future. + static var exchangeCoordinator: TokenExchangeCoordinator = DefaultTokenExchangeCoordinator() + /// The unique identifier for this token. public internal(set) var id: String @@ -39,10 +46,10 @@ public final class Token: Codable, Equatable, Hashable, Expires { public let accessToken: String /// The scopes requested when this token was generated. - public let scope: String? + public var scope: String? { self[.scope] } /// The refresh token, if requested. - public let refreshToken: String? + public var refreshToken: String? { self[.refreshToken] } /// The ID token, if requested. /// @@ -53,13 +60,20 @@ public final class Token: Codable, Equatable, Hashable, Expires { public let context: Context /// The Device secret, if requested in scope. - public let deviceSecret: String? + public var deviceSecret: String? { self[.deviceSecret] } + + /// The type of token issued to the client when using Token Exchange Flow. + public var issuedTokenType: String? { self[.issuedTokenType] } + + /// The claim payload container for this token + public var payload: [String: Any] { jsonPayload.jsonValue.anyValue as? [String: Any] ?? [:] } /// Indicates whether or not the token is being refreshed. public var isRefreshing: Bool { refreshAction != nil } + let jsonPayload: AnyJSON internal var refreshAction: CoalescedResult>? /// Return the relevant token string for the given type. @@ -133,6 +147,9 @@ public final class Token: Codable, Equatable, Hashable, Expires { } } + @_documentation(visibility: private) + public static let jsonDecoder = JSONDecoder() + public static func == (lhs: Token, rhs: Token) -> Bool { lhs.context == rhs.context && lhs.accessToken == rhs.accessToken && @@ -150,82 +167,51 @@ public final class Token: Codable, Equatable, Hashable, Expires { hasher.combine(deviceSecret) } - required init(id: String, - issuedAt: Date, - tokenType: String, - expiresIn: TimeInterval, - accessToken: String, - scope: String?, - refreshToken: String?, - idToken: JWT?, - deviceSecret: String?, - context: Context) + init(id: String, + issuedAt: Date, + context: Context, + json: AnyJSON) throws { self.id = id self.issuedAt = issuedAt - self.tokenType = tokenType - self.expiresIn = expiresIn - self.accessToken = accessToken - self.scope = scope - self.refreshToken = refreshToken - self.idToken = idToken - self.deviceSecret = deviceSecret self.context = context - } - - public required convenience init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) + self.jsonPayload = json - let context: Context - if container.contains(.context) { - context = try container.decode(Context.self, forKey: .context) - } else if let configuration = decoder.userInfo[.apiClientConfiguration] as? OAuth2Client.Configuration { - context = Context(configuration: configuration, - clientSettings: decoder.userInfo[.clientSettings]) + let payload = json.jsonValue.anyValue as? [String: Any] ?? [:] + if let value = payload[TokenClaim.idToken.rawValue] as? String { + idToken = try JWT(value) } else { - throw TokenError.contextMissing + idToken = nil } - - let id: String - if let userInfoId = decoder.userInfo[.tokenId] as? String { - id = userInfoId - } else if container.contains(.id) { - id = try container.decode(String.self, forKey: .id) - } else { - id = UUID().uuidString - } - - var idToken: JWT? - if let idTokenString = try container.decodeIfPresent(String.self, forKey: .idToken) { - idToken = try JWT(idTokenString) + + // Ensure an access token is provided. + if let value: String = TokenClaim.optionalValue(.accessToken, in: payload) { + accessToken = value } - // There are some conditions where a missing or null access_token is acceptable. - // Detect this condition, and assign an empty string where necessary. - var accessToken: String - do { - accessToken = try container.decode(String.self, forKey: .accessToken) - } catch { - if let request = decoder.userInfo[.request] as? (any OAuth2TokenRequest), - let acrValues = request.acrValues, - acrValues.contains("urn:okta:app:mfa:attestation") - { - accessToken = "" - } else { - throw error - } + // When the custom MFA attestation ACR value is used, allow for + // an empty / unspecified access token. + else if let acrValues = idToken?.authenticationClassReference, + acrValues.contains("urn:okta:app:mfa:attestation") + { + accessToken = "" } - self.init(id: id, - issuedAt: try container.decodeIfPresent(Date.self, forKey: .issuedAt) ?? Date.nowCoordinated, - tokenType: try container.decode(String.self, forKey: .tokenType), - expiresIn: try container.decode(TimeInterval.self, forKey: .expiresIn), - accessToken: accessToken, - scope: try container.decodeIfPresent(String.self, forKey: .scope), - refreshToken: try container.decodeIfPresent(String.self, forKey: .refreshToken), - idToken: idToken, - deviceSecret: try container.decodeIfPresent(String.self, forKey: .deviceSecret), - context: context) + // Throw an error when no access token is available. + else { + throw ClaimError.missingRequiredValue(key: TokenClaim.accessToken.rawValue) + } + + tokenType = try TokenClaim.value(.tokenType, in: payload) + expiresIn = try TokenClaim.value(.expiresIn, in: payload) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeysV2.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(issuedAt, forKey: .issuedAt) + try container.encode(context, forKey: .context) + try container.encode(jsonPayload.stringValue, forKey: .rawValue) } } @@ -248,7 +234,7 @@ extension Token { #endif extension Token { - enum CodingKeys: String, CodingKey, CaseIterable { + enum CodingKeysV1: String, CodingKey, CaseIterable { case id case issuedAt case tokenType @@ -260,8 +246,16 @@ extension Token { case deviceSecret case context } + + enum CodingKeysV2: String, CodingKey, CaseIterable { + case id + case issuedAt + case context + case rawValue + } } +@_documentation(visibility: private) extension CodingUserInfoKey { // swiftlint:disable force_unwrapping public static let tokenId = CodingUserInfoKey(rawValue: "tokenId")! @@ -270,3 +264,23 @@ extension CodingUserInfoKey { public static let request = CodingUserInfoKey(rawValue: "request")! // swiftlint:enable force_unwrapping } + +extension Token { + public enum TokenClaim: String, IsClaim, CaseIterable { + // Core OAuth 2.0 (RFC 6749) + case accessToken = "access_token" + case tokenType = "token_type" + case expiresIn = "expires_in" + case refreshToken = "refresh_token" + case scope + + // OpenID Connect (OIDC) + case idToken = "id_token" + + // OAuth 2.0 Token Exchange (RFC 8693) + case issuedTokenType = "issued_token_type" + + // OpenID Connect Native SSO for Mobile Apps 1.0 + case deviceSecret = "device_secret" + } +} diff --git a/Sources/AuthFoundation/Token Management/TokenExchangeCoordinator.swift b/Sources/AuthFoundation/Token Management/TokenExchangeCoordinator.swift new file mode 100644 index 000000000..b292c5ab6 --- /dev/null +++ b/Sources/AuthFoundation/Token Management/TokenExchangeCoordinator.swift @@ -0,0 +1,25 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (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 + +protocol TokenExchangeCoordinator { + /// When refreshing a token, not all values are always returned, especially the refresh token or device secret. + /// + /// This function is used to merge these values from an existing token instance to preserve them during a refresh. + /// - Parameters: + /// - token: The token into which a new response is being merged. + /// - payload: The current token payload. + /// - newPayload: The new token payload to be merged. + /// - Returns: The payload for the token by merging the old values with the new ones. + func merge(_ token: Token, payload: [String: Any], with newPayload: [String: Any]) throws -> [String: Any] +} diff --git a/Sources/AuthFoundation/User Management/Credential+Extensions.swift b/Sources/AuthFoundation/User Management/Credential+Extensions.swift index 864752557..496fa4ea7 100644 --- a/Sources/AuthFoundation/User Management/Credential+Extensions.swift +++ b/Sources/AuthFoundation/User Management/Credential+Extensions.swift @@ -16,6 +16,9 @@ import Foundation import FoundationNetworking #endif +/// Custom notifications sent from the SDK for key events. +/// +/// When important events occur within the SDK, your application may need to update its state when the default credential changes. Therefore you can observe the `defaultCredentialChanged` notification. extension Notification.Name { /// Notification broadcast when the ``Credential/default`` value changes. public static let defaultCredentialChanged = Notification.Name("com.okta.defaultCredentialChanged") diff --git a/Sources/AuthFoundation/Utilities/Data+Extensions.swift b/Sources/AuthFoundation/Utilities/Data+Extensions.swift index 892ef4c0e..e6b5c70f9 100644 --- a/Sources/AuthFoundation/Utilities/Data+Extensions.swift +++ b/Sources/AuthFoundation/Utilities/Data+Extensions.swift @@ -26,6 +26,7 @@ internal extension Int { } #endif +@_documentation(visibility: internal) extension Data { /// Produces a SHA256 hash of the supplied data. /// - Returns: SHA256 representation of the data. @@ -47,6 +48,7 @@ extension Data { /// Encodes the data as a URL-safe Base64 string. /// - Returns: Base64 URL-encoded string. + @_documentation(visibility: internal) public var base64URLEncodedString: String { return base64EncodedString() .replacingOccurrences(of: "+", with: "-") diff --git a/Sources/AuthFoundation/Utilities/Dictionary+Extensions.swift b/Sources/AuthFoundation/Utilities/Dictionary+Extensions.swift index fb7a50f01..5b97f16f6 100644 --- a/Sources/AuthFoundation/Utilities/Dictionary+Extensions.swift +++ b/Sources/AuthFoundation/Utilities/Dictionary+Extensions.swift @@ -13,6 +13,7 @@ import Foundation extension Dictionary where Key == String, Value == String { + @_documentation(visibility: internal) public var percentQueryEncoded: String { var cs = CharacterSet.urlQueryAllowed cs.remove("+") @@ -30,6 +31,7 @@ extension Dictionary where Key == String, Value == String { } extension Dictionary where Key == String, Value == (any APIRequestArgument)? { + @_documentation(visibility: internal) public var percentQueryEncoded: String { compactMapValues { $0?.stringValue }.percentQueryEncoded } diff --git a/Sources/AuthFoundation/Utilities/JSONDecodable.swift b/Sources/AuthFoundation/Utilities/JSONDecodable.swift index ddec68c79..0c98988f7 100644 --- a/Sources/AuthFoundation/Utilities/JSONDecodable.swift +++ b/Sources/AuthFoundation/Utilities/JSONDecodable.swift @@ -15,3 +15,8 @@ import Foundation public protocol JSONDecodable { static var jsonDecoder: JSONDecoder { get } } + +extension JSONDecodable { + @_documentation(visibility: internal) + public static var jsonDecoder: JSONDecoder { JSONDecoder() } +} diff --git a/Sources/AuthFoundation/Utilities/JSONValue.swift b/Sources/AuthFoundation/Utilities/JSONValue.swift index 70bce3971..173a45db5 100644 --- a/Sources/AuthFoundation/Utilities/JSONValue.swift +++ b/Sources/AuthFoundation/Utilities/JSONValue.swift @@ -12,37 +12,139 @@ import Foundation -public enum JSONValueError: Error { - case cannotDecode(value: Any) +public enum JSONError: Error { + case cannotDecode(value: Any?) + case invalidContentEncoding } -/// Represent mixed JSON values as instances of `Any`. This is used to expose API response values to Swift native types -/// where Swift enums are not supported. -public enum JSONValue: Equatable { +@_documentation(visibility: private) +@available(*, deprecated, renamed: "JSON") +public typealias JSONValue = JSON + +/// Efficiently represents ``JSON`` values, and exchanges between its String or Data representations. +/// +/// JSON data may be imported from multiple sources, be it Data, a String, or an alread-parsed JSON object. Transforming data between these states, and dealing with error conditions every time, can be cumbersome. AnyJSON is a convenience wrapper class that allows underlying JSON to be lazily mapped between types as needed. +public class AnyJSON { + private enum Value { + case json(JSON) + case string(String) + case data(Data) + } + + private let value: Value + + /// The string encoding of the JSON data. + public lazy var stringValue: String = { + if case let .string(string) = value { + return string + } + return String(data: dataValue, encoding: .utf8) ?? "" + }() + + /// The data encoding of the JSON value. + public lazy var dataValue: Data = { + switch value { + case .json(let json): + guard let anyValue = json.anyValue else { + return Data() + } + return (try? JSONSerialization.data(withJSONObject: anyValue)) ?? Data() + case .string(let string): + return string.data(using: .utf8) ?? Data() + case .data(let data): + return data + } + }() + + /// The ``JSON`` representation of the JSON data. + public lazy var jsonValue: JSON = { + switch value { + case .json(let json): + return json + case .string(let string): + return (try? JSON(string)) ?? JSON.null + case .data(let data): + return (try? JSON(data)) ?? JSON.null + } + }() + + /// Initializes the JSON data based on a string value. + /// - Parameter string: JSON string. + public init(_ string: String) { + value = .string(string) + } + + + /// Initializes the JSON data based on a data value. + /// - Parameter data: JSON data. + public init(_ data: Data) { + value = .data(data) + } + + /// Initializes the JSON data based on a ``JSON`` value. + /// - Parameter json: The ``JSON`` value. + public init(_ json: JSON) { + value = .json(json) + } +} + +/// Represent mixed JSON values as instances of `Any`. This is used to expose API response values to Swift native types where Swift enums are not supported. +public enum JSON: Equatable { + /// String JSON key value. case string(String) - case number(Double) + + /// Number JSON key value. + case number(NSNumber) + + /// Boolean JSON key value. case bool(Bool) - case dictionary([String: JSONValue]) - case array([JSONValue]) - case object(Any) + + /// Object JSON key value, containing its own nested key/value pairs. + case object([String: JSON]) + + /// Array JSON key value, containing its own nested JSON values. + case array([JSON]) + + /// Null JSON key value. case null + /// Initializes a JSON object from a variety of supported types. + /// - Parameter value: Value to represent as a JSON stru ture. public init(_ value: Any?) throws { + guard let value = value + else { + self = .null + return + } + if let value = value as? String { self = .string(value) } else if let value = value as? NSNumber { - self = .number(value.doubleValue) + self = .number(value) } else if let value = value as? Bool { self = .bool(value) } else if let value = value as? [String: Any] { - self = .dictionary(try value.mapValues({ try JSONValue($0) })) + self = .object(try value.mapValues({ try JSON($0) })) } else if let value = value as? [Any] { - self = .array(try value.map({ try JSONValue($0) })) - } else if value == nil { - self = .null + self = .array(try value.map({ try JSON($0) })) } else { - throw JSONValueError.cannotDecode(value: value as Any) + throw JSONError.cannotDecode(value: value as Any) + } + } + + /// Initializes a JSON object from its string representation. + /// - Parameter value: The String value for a JSON object. + public init(_ value: String) throws { + guard let data = value.data(using: .utf8) else { + throw JSONError.invalidContentEncoding } + try self.init(data) + } + + /// Initializes a JSON object from its data representation. + /// - Parameter value: The data value for a JSON object. + public init(_ value: Data) throws { + try self.init(JSONSerialization.jsonObject(with: value)) } /// Returns the value as an instance of `Any`. @@ -54,20 +156,34 @@ public enum JSONValue: Equatable { return value case let .bool(value): return value - case let .dictionary(value): + case let .object(value): return value.reduce(into: [String: Any?]()) { $0[$1.key] = $1.value.anyValue } case let .array(value): return value.map { $0.anyValue } - case let .object(value): - return value case .null: return nil } } - public static func == (lhs: JSONValue, rhs: JSONValue) -> Bool { + public subscript(index: Int) -> Any? { + guard case let .array(array) = self else { + return nil + } + + return array[index] + } + + public subscript(key: String) -> Any? { + guard case let .object(dictionary) = self else { + return nil + } + + return dictionary[key] + } + + public static func == (lhs: JSON, rhs: JSON) -> Bool { switch (lhs, rhs) { case (.string(let lhsValue), .string(let rhsValue)): return lhsValue == rhsValue @@ -75,18 +191,10 @@ public enum JSONValue: Equatable { return lhsValue == rhsValue case (.bool(let lhsValue), .bool(let rhsValue)): return lhsValue == rhsValue - case (.dictionary(let lhsValue), .dictionary(let rhsValue)): + case (.object(let lhsValue), .object(let rhsValue)): return lhsValue == rhsValue case (.array(let lhsValue), .array(let rhsValue)): return lhsValue == rhsValue - case (.object(let lhsValue), .object(let rhsValue)): - if let lhsValue = lhsValue as? AnyHashable, - let rhsValue = rhsValue as? AnyHashable - { - return lhsValue == rhsValue - } else { - return false - } case (.null, .null): return true default: @@ -95,18 +203,20 @@ public enum JSONValue: Equatable { } } -extension JSONValue: Codable { +extension JSON: Codable { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let value = try? container.decode(String.self) { self = .string(value) + } else if let value = try? container.decode(Int.self) { + self = .number(value as NSNumber) } else if let value = try? container.decode(Double.self) { - self = .number(value) + self = .number(value as NSNumber) } else if let value = try? container.decode(Bool.self) { self = .bool(value) - } else if let value = try? container.decode([String: JSONValue].self) { - self = .dictionary(value) - } else if let value = try? container.decode([JSONValue].self) { + } else if let value = try? container.decode([String: JSON].self) { + self = .object(value) + } else if let value = try? container.decode([JSON].self) { self = .array(value) } else if container.decodeNil() { self = .null @@ -122,27 +232,36 @@ extension JSONValue: Codable { case let .string(value): try container.encode(value) case let .number(value): - try container.encode(value) + if value.isFloatingPoint { + try container.encode(value.doubleValue) + } else { + try container.encode(value.intValue) + } case let .bool(value): try container.encode(value) - case let .dictionary(value): + case let .object(value): try container.encode(value) case let .array(value): try container.encode(value) - case let .object(value): - if let value = value as? Codable { - try container.encode(value) - } else { - throw EncodingError.invalidValue(value, .init(codingPath: encoder.codingPath, - debugDescription: "Value is not encodable at \(encoder.codingPath)")) - } case .null: try container.encodeNil() } } } -extension JSONValue: CustomDebugStringConvertible { +fileprivate extension NSNumber { + var isFloatingPoint: Bool { + let type = CFNumberGetType(self as CFNumber) + switch type { + case .floatType, .float32Type, .float64Type, .cgFloatType, .doubleType: + return true + default: + return false + } + } +} + +extension JSON: CustomDebugStringConvertible { public var debugDescription: String { switch self { case .string(let str): @@ -153,12 +272,6 @@ extension JSONValue: CustomDebugStringConvertible { return bool ? "true" : "false" case .null: return "null" - case .object(let obj): - if let obj = obj as? CustomDebugStringConvertible { - return obj.debugDescription - } else { - return "Custom object \(String(describing: obj))" - } default: let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted] diff --git a/Sources/AuthFoundation/Version.swift b/Sources/AuthFoundation/Version.swift index 25ab28892..711f48098 100644 --- a/Sources/AuthFoundation/Version.swift +++ b/Sources/AuthFoundation/Version.swift @@ -13,5 +13,6 @@ import Foundation // swiftlint:disable identifier_name +@_documentation(visibility: private) public let Version = SDKVersion(sdk: "okta-authfoundation-swift", version: "1.8.2") // swiftlint:enable identifier_name diff --git a/Sources/OktaDirectAuth/Version.swift b/Sources/OktaDirectAuth/Version.swift index 96c6c8956..de522c6e6 100644 --- a/Sources/OktaDirectAuth/Version.swift +++ b/Sources/OktaDirectAuth/Version.swift @@ -13,5 +13,6 @@ @_exported import AuthFoundation // swiftlint:disable identifier_name +@_documentation(visibility: private) public let Version = SDKVersion(sdk: "okta-directauth-swift", version: "1.8.2") // swiftlint:enable identifier_name diff --git a/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialRequestOptions.swift b/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialRequestOptions.swift index e2b664958..ce7591bd6 100644 --- a/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialRequestOptions.swift +++ b/Sources/OktaDirectAuth/WebAuthn/PublicKeyCredentialRequestOptions.swift @@ -65,7 +65,7 @@ extension WebAuthn { timeout = nil } - if let jsonValues = try container.decodeIfPresent([String: JSONValue].self, forKey: .extensions) { + if let jsonValues = try container.decodeIfPresent([String: JSON].self, forKey: .extensions) { extensions = jsonValues.mapValues({ $0.anyValue }) } else { extensions = nil @@ -84,7 +84,7 @@ extension WebAuthn { } if let extensions = extensions { - try container.encode(try extensions.mapValues({ try JSONValue($0) }), forKey: .extensions) + try container.encode(try extensions.mapValues({ try JSON($0) }), forKey: .extensions) } } } diff --git a/Sources/OktaOAuth2/Version.swift b/Sources/OktaOAuth2/Version.swift index d52925ea9..449b10007 100644 --- a/Sources/OktaOAuth2/Version.swift +++ b/Sources/OktaOAuth2/Version.swift @@ -13,5 +13,6 @@ @_exported import AuthFoundation // swiftlint:disable identifier_name +@_documentation(visibility: private) public let Version = SDKVersion(sdk: "okta-oauth2-swift", version: "1.8.2") // swiftlint:enable identifier_name diff --git a/Sources/WebAuthenticationUI/Version.swift b/Sources/WebAuthenticationUI/Version.swift index 905929c85..c0dd288d5 100644 --- a/Sources/WebAuthenticationUI/Version.swift +++ b/Sources/WebAuthenticationUI/Version.swift @@ -14,5 +14,6 @@ import Foundation import AuthFoundation // swiftlint:disable identifier_name +@_documentation(visibility: private) public let Version = SDKVersion(sdk: "okta-webauthenticationui-swift", version: "1.8.2") // swiftlint:enable identifier_name diff --git a/Tests/AuthFoundationTests/ClaimTests.swift b/Tests/AuthFoundationTests/ClaimTests.swift index 834f98468..64ffee7b4 100644 --- a/Tests/AuthFoundationTests/ClaimTests.swift +++ b/Tests/AuthFoundationTests/ClaimTests.swift @@ -16,7 +16,7 @@ import TestCommon struct TestClaims: HasClaims { enum TestClaim: String, IsClaim { - case firstName, lastName, modifiedDate, webpage, roles + case firstName, lastName, modifiedDate, webpage, roles, tags } enum Role: String, ClaimConvertable, IsClaim { @@ -45,7 +45,11 @@ final class ClaimTests: XCTestCase { "lastName": "Doe", "modifiedDate": dateString, "webpage": "https://example.com/jane.doe/", - "roles": ["admin", "user"] + "roles": ["admin", "user"], + "tags": [ + "popular": "Popular Items", + "normal": "Normal Items", + ] ]) let webpage = try XCTUnwrap(URL(string: "https://example.com/jane.doe/")) @@ -57,17 +61,35 @@ final class ClaimTests: XCTestCase { XCTAssertEqual(container["modifiedDate"], dateString) XCTAssertEqual(container[.modifiedDate], dateString) - XCTAssertEqual(container.value(Date.self, for: "modifiedDate"), date) + XCTAssertEqual(date, try container.value(for: "modifiedDate")) XCTAssertEqual(container["roles"], ["admin", "user"]) XCTAssertEqual(container[.roles], ["admin", "user"]) - XCTAssertEqual(container.value([String].self, for: "roles"), ["admin", "user"]) - XCTAssertEqual(container.arrayValue(String.self, for: "roles"), ["admin", "user"]) - XCTAssertEqual(container.arrayValue(TestClaims.Role.self, for: "roles"), [.admin, .user]) + XCTAssertEqual([TestClaims.Role.admin, TestClaims.Role.user], container[.roles]) + XCTAssertEqual(try container.value(for: "roles"), ["admin", "user"]) + XCTAssertEqual(try container.value(for: "roles"), ["admin", "user"]) + XCTAssertEqual(try container.value(for: "roles") as [TestClaims.Role], [.admin, .user]) + + XCTAssertEqual(try container.value(for: "tags"), [ + "popular": "Popular Items", + "normal": "Normal Items", + ]) + XCTAssertEqual(try container.value(for: .tags), [ + "popular": "Popular Items", + "normal": "Normal Items", + ]) + XCTAssertEqual(container["tags"], [ + "popular": "Popular Items", + "normal": "Normal Items", + ]) + XCTAssertEqual(container[.tags], [ + "popular": "Popular Items", + "normal": "Normal Items", + ]) XCTAssertEqual(container["webpage"], "https://example.com/jane.doe/") XCTAssertEqual(container[.webpage], "https://example.com/jane.doe/") - XCTAssertEqual(container.value(URL.self, for: "webpage"), webpage) + XCTAssertEqual(webpage, try container.value(for: "webpage")) var url: URL? url = container["webpage"] diff --git a/Tests/AuthFoundationTests/CredentialCoordinatorTests.swift b/Tests/AuthFoundationTests/CredentialCoordinatorTests.swift index 0151d47cd..f85220145 100644 --- a/Tests/AuthFoundationTests/CredentialCoordinatorTests.swift +++ b/Tests/AuthFoundationTests/CredentialCoordinatorTests.swift @@ -24,19 +24,19 @@ final class UserCoordinatorTests: XCTestCase { var storage: UserDefaultsTokenStorage! var coordinator: CredentialCoordinatorImpl! - let token = Token(id: "TokenId", - issuedAt: Date(), - tokenType: "Bearer", - expiresIn: 300, - accessToken: "abcd123", - scope: "openid", - refreshToken: nil, - idToken: nil, - deviceSecret: nil, - context: Token.Context(configuration: .init(baseURL: URL(string: "https://example.com")!, - clientId: "clientid", - scopes: "openid"), - clientSettings: nil)) + let token = try! Token(id: "TokenId", + issuedAt: Date(), + tokenType: "Bearer", + expiresIn: 300, + accessToken: "abcd123", + scope: "openid", + refreshToken: nil, + idToken: nil, + deviceSecret: nil, + context: Token.Context(configuration: .init(baseURL: URL(string: "https://example.com")!, + clientId: "clientid", + scopes: "openid"), + clientSettings: nil)) override func setUpWithError() throws { userDefaults = UserDefaults(suiteName: name) diff --git a/Tests/AuthFoundationTests/CredentialRevokeTests.swift b/Tests/AuthFoundationTests/CredentialRevokeTests.swift index 923e24090..625c1443e 100644 --- a/Tests/AuthFoundationTests/CredentialRevokeTests.swift +++ b/Tests/AuthFoundationTests/CredentialRevokeTests.swift @@ -19,19 +19,19 @@ final class CredentialTests: XCTestCase { var credential: Credential! var urlSession: URLSessionMock! - let token = Token(id: "TokenId", - issuedAt: Date(), - tokenType: "Bearer", - expiresIn: 300, - accessToken: "abcd123", - scope: "openid", - refreshToken: "refresh123", - idToken: nil, - deviceSecret: "device123", - context: Token.Context(configuration: .init(baseURL: URL(string: "https://example.com/oauth2/default")!, - clientId: "clientid", - scopes: "openid"), - clientSettings: [ "client_id": "foo" ])) + let token = try! Token(id: "TokenId", + issuedAt: Date(), + tokenType: "Bearer", + expiresIn: 300, + accessToken: "abcd123", + scope: "openid", + refreshToken: "refresh123", + idToken: nil, + deviceSecret: "device123", + context: Token.Context(configuration: .init(baseURL: URL(string: "https://example.com/oauth2/default")!, + clientId: "clientid", + scopes: "openid"), + clientSettings: [ "client_id": "foo" ])) override func setUpWithError() throws { coordinator = MockCredentialCoordinator() diff --git a/Tests/AuthFoundationTests/DefaultCredentialDataSourceTests.swift b/Tests/AuthFoundationTests/DefaultCredentialDataSourceTests.swift index 3d2cb1969..8b242fc35 100644 --- a/Tests/AuthFoundationTests/DefaultCredentialDataSourceTests.swift +++ b/Tests/AuthFoundationTests/DefaultCredentialDataSourceTests.swift @@ -63,17 +63,17 @@ final class DefaultCredentialDataSourceTests: XCTestCase { func testCredentials() throws { XCTAssertEqual(dataSource.credentialCount, 0) - let token = Token(id: "TokenId", - issuedAt: Date(), - tokenType: "Bearer", - expiresIn: 300, - accessToken: "abcd123", - scope: "openid", - refreshToken: nil, - idToken: nil, - deviceSecret: nil, - context: Token.Context(configuration: configuration, - clientSettings: nil)) + let token = try! Token(id: "TokenId", + issuedAt: Date(), + tokenType: "Bearer", + expiresIn: 300, + accessToken: "abcd123", + scope: "openid", + refreshToken: nil, + idToken: nil, + deviceSecret: nil, + context: Token.Context(configuration: configuration, + clientSettings: nil)) XCTAssertFalse(dataSource.hasCredential(for: token)) diff --git a/Tests/AuthFoundationTests/JSONValueTests.swift b/Tests/AuthFoundationTests/JSONValueTests.swift index 22e2f20b7..959875ad1 100644 --- a/Tests/AuthFoundationTests/JSONValueTests.swift +++ b/Tests/AuthFoundationTests/JSONValueTests.swift @@ -13,12 +13,12 @@ import XCTest @testable import AuthFoundation -class JSONValueTests: XCTestCase { +class JSONTests: XCTestCase { let decoder = JSONDecoder() let encoder = JSONEncoder() func testString() throws { - let value = JSONValue.string("Test String") + let value = JSON.string("Test String") XCTAssertNotNil(value) XCTAssertEqual(value.debugDescription, "\"Test String\"") @@ -30,14 +30,14 @@ class JSONValueTests: XCTestCase { let encoded = try encoder.encode(value) XCTAssertNotNil(encoded) - let decoded = try decoder.decode(JSONValue.self, from: encoded) + let decoded = try decoder.decode(JSON.self, from: encoded) XCTAssertEqual(decoded, value) } - func testNumber() throws { - let value = JSONValue.number(1) + func testInt() throws { + let value = JSON.number(1) XCTAssertNotNil(value) - XCTAssertEqual(value.debugDescription, "1.0") + XCTAssertEqual(value.debugDescription, "1") if let numberValue = value.anyValue as? NSNumber { XCTAssertEqual(numberValue, NSNumber(integerLiteral: 1)) @@ -47,15 +47,32 @@ class JSONValueTests: XCTestCase { let encoded = try encoder.encode(value) XCTAssertNotNil(encoded) - let decoded = try decoder.decode(JSONValue.self, from: encoded) + let decoded = try decoder.decode(JSON.self, from: encoded) + XCTAssertEqual(decoded, value) + } + + func testDouble() throws { + let value = JSON.number(1.5) + XCTAssertNotNil(value) + XCTAssertEqual(value.debugDescription, "1.5") + + if let numberValue = value.anyValue as? NSNumber { + XCTAssertEqual(numberValue, NSNumber(floatLiteral: 1.5)) + } else { + XCTFail("Object not a number") + } + + let encoded = try encoder.encode(value) + XCTAssertNotNil(encoded) + let decoded = try decoder.decode(JSON.self, from: encoded) XCTAssertEqual(decoded, value) } func testBool() throws { - let value = JSONValue.bool(true) + let value = JSON.bool(true) XCTAssertNotNil(value) XCTAssertEqual(value.debugDescription, "true") - XCTAssertEqual(JSONValue.bool(false).debugDescription, "false") + XCTAssertEqual(JSON.bool(false).debugDescription, "false") if let boolValue = value.anyValue as? NSNumber { XCTAssertEqual(boolValue, NSNumber(booleanLiteral: true)) @@ -65,12 +82,12 @@ class JSONValueTests: XCTestCase { let encoded = try encoder.encode(value) XCTAssertNotNil(encoded) - let decoded = try decoder.decode(JSONValue.self, from: encoded) + let decoded = try decoder.decode(JSON.self, from: encoded) XCTAssertEqual(decoded, value) } func testNull() throws { - let value = JSONValue.null + let value = JSON.null XCTAssertNotNil(value) XCTAssertEqual(value.debugDescription, "null") @@ -78,12 +95,12 @@ class JSONValueTests: XCTestCase { let encoded = try encoder.encode(value) XCTAssertNotNil(encoded) - let decoded = try decoder.decode(JSONValue.self, from: encoded) + let decoded = try decoder.decode(JSON.self, from: encoded) XCTAssertEqual(decoded, value) } func testArray() throws { - let value = JSONValue.array([JSONValue.string("foo"), JSONValue.string("bar")]) + let value = JSON.array([JSON.string("foo"), JSON.string("bar")]) XCTAssertNotNil(value) XCTAssertEqual(value.debugDescription, """ [ @@ -100,15 +117,15 @@ class JSONValueTests: XCTestCase { let encoded = try encoder.encode(value) XCTAssertNotNil(encoded) - let decoded = try decoder.decode(JSONValue.self, from: encoded) + let decoded = try decoder.decode(JSON.self, from: encoded) XCTAssertEqual(decoded, value) } func testDictionary() throws { - let value = JSONValue.dictionary( - ["foo": JSONValue.dictionary( - ["bar": JSONValue.array( - [JSONValue.string("woof")]) + let value = JSON.object( + ["foo": JSON.object( + ["bar": JSON.array( + [JSON.string("woof")]) ]) ]) XCTAssertNotNil(value) @@ -129,25 +146,23 @@ class JSONValueTests: XCTestCase { let encoded = try encoder.encode(value) XCTAssertNotNil(encoded) - let decoded = try decoder.decode(JSONValue.self, from: encoded) + let decoded = try decoder.decode(JSON.self, from: encoded) XCTAssertEqual(decoded, value) + + let asDictionary = try XCTUnwrap(value.anyValue as? [String: Any]) + let foo = try XCTUnwrap(asDictionary["foo"] as? [String: Any]) + let bar = try XCTUnwrap(foo["bar"] as? [String]) + XCTAssertEqual(bar.first, "woof") } - func testObject() throws { - let object = URL(string: "https://example.com")! - let value = JSONValue.object(object) - XCTAssertNotNil(value) - XCTAssertEqual(value.debugDescription, "https://example.com") - - if let urlValue = value.anyValue as? URL { - XCTAssertEqual(urlValue, URL(string: "https://example.com")) - } else { - XCTFail("Object not a URL") - } + func testConversions() throws { + let json = try decoder.decode(JSON.self, + from: try data(from: .module, + for: "openid-configuration", + in: "MockResponses")) + let object = try XCTUnwrap(json.anyValue as? [String: Any]) + let array = try XCTUnwrap(object["claims_supported"] as? [String]) - XCTAssertEqual(value, JSONValue.object(URL(string: "https://example.com")!)) - let encoded = try encoder.encode(value) - let decoded = try decoder.decode(JSONValue.self, from: encoded) - XCTAssertEqual(decoded.anyValue as? String, "https://example.com") + XCTAssertEqual(array.count, 31) } } diff --git a/Tests/AuthFoundationTests/KeychainTokenStorageTests.swift b/Tests/AuthFoundationTests/KeychainTokenStorageTests.swift index 2f378be66..bc7ca8384 100644 --- a/Tests/AuthFoundationTests/KeychainTokenStorageTests.swift +++ b/Tests/AuthFoundationTests/KeychainTokenStorageTests.swift @@ -35,7 +35,7 @@ final class KeychainTokenStorageTests: XCTestCase { "v_Data": Data() ] as CFDictionary - let token = Token(id: "TokenId", + let token = try! Token(id: "TokenId", issuedAt: Date(), tokenType: "Bearer", expiresIn: 300, diff --git a/Tests/AuthFoundationTests/MockResponses/token-mfa_attestation.json b/Tests/AuthFoundationTests/MockResponses/token-mfa_attestation.json index 23ac9d307..fa2e9e062 100644 --- a/Tests/AuthFoundationTests/MockResponses/token-mfa_attestation.json +++ b/Tests/AuthFoundationTests/MockResponses/token-mfa_attestation.json @@ -3,5 +3,5 @@ "expires_in": 0, "access_token": null, "scope": "openid", - "id_token": "eyJraWQiOiI5RmR5LXYxOXVRM2E5TTlzV1lNeGNoblNMckFkMjZWWFhIdkZVZXpsTWtNIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiIwMHViNDF6N21nek5xcnlNdjY5NiIsInZlciI6MSwiaXNzIjoiaHR0cHM6Ly9leGFtcGxlLmNvbSIsImF1ZCI6InVuaXRfdGVzdF9jbGllbnRfaWQiLCJpYXQiOjE3MTUyNzQ4NzYsImV4cCI6MTcxNTI3ODQ3NiwianRpIjoiSUQuQVItU2s4bkR1clk0ZTJ2RW1ORm9iQmFFZHZIYXNaNVQyUGVmZ2RSWXdGQSIsImFtciI6WyJvdHAiXSwiaWRwIjoiMDBvOGZvdTdzUmFHR3dkbjQ2OTYiLCJhdXRoX3RpbWUiOjE3MTUyNzQ4NzZ9.eQkqeSMDu8CVYhusfixh1ZfcrmXe03PJfHEcJDwMG1vSrTGR5wyMY1TmlCzQ1WaQ7LI8HrBJfBbh2_keYn7j5qYp_K0zV7Y9HNA2An8hCBfeELFdtcWrTxUmjgNTAa7iuTCPWma8lyYOD3Q89mdMloTYDFAic7ZDIVGTBDoyYJR5OaG-LpnNTSCebhRFnqfWoyIThnTTv0VIwZJ2qiGbCZdnwoZzEisNZcpjOmT4ML_dtA9S2n1j2ZJBwsT2mvmAPmYMFJDLUXWEsOrEJ475RsnQMkN7wzL4931gUU-crzCl7WigCSdTzah1BsKWI6Ya_9qzde0iOD9Of59Y6HBPRw" + "id_token": "eyJraWQiOiI5RmR5LXYxOXVRM2E5TTlzV1lNeGNoblNMckFkMjZWWFhIdkZVZXpsTWtNIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiIwMHViNDF6N21nek5xcnlNdjY5NiIsInZlciI6MSwiaXNzIjoiaHR0cHM6Ly9leGFtcGxlLmNvbSIsImF1ZCI6InVuaXRfdGVzdF9jbGllbnRfaWQiLCJpYXQiOjE3MTUyNzQ4NzYsImV4cCI6MTcxNTI3ODQ3NiwianRpIjoiSUQuQVItU2s4bkR1clk0ZTJ2RW1ORm9iQmFFZHZIYXNaNVQyUGVmZ2RSWXdGQSIsImFtciI6WyJvdHAiXSwiYWNyIjoidXJuOm9rdGE6YXBwOm1mYTphdHRlc3RhdGlvbiIsImlkcCI6IjAwbzhmb3U3c1JhR0d3ZG40Njk2IiwiYXV0aF90aW1lIjoxNzE1Mjc0ODc2fQ.bKd_BG-TpJAjr_-n9WV1xR6c1GJCrHRcFwPED7Xvju_3-I0HVnrj3Z33LBSFnJ7S8qknfAbFNoPpIvsNng4tRudPKPuwN6SbXUneGweO3JSfwfIJHSftOt8mjgGPW2eJlFqkw3ipD0pHl3BQpI3LubppiX_xQkmtCBk52F1AwLb_nvd-ZV4fdH579nioc2ZoOO2zZs5KZgNBrVZgNdlYA_n81Tv3PhHoVpzMejSEYMZfGrft54inkky4o9RSRZTjVl1Br9Zq5YO7ImmL64WVH95jDPD0UuMXkVefw3TXTgrS4PtNjGi_VNtxbpQUQ5qvqJAX7TA3r7ttY-kwbhxXoA" } diff --git a/Tests/AuthFoundationTests/MockResponses/token-no_access_token.json b/Tests/AuthFoundationTests/MockResponses/token-no_access_token.json new file mode 100644 index 000000000..0dc980920 --- /dev/null +++ b/Tests/AuthFoundationTests/MockResponses/token-no_access_token.json @@ -0,0 +1,7 @@ +{ + "token_type": "Bearer", + "expires_in": 0, + "access_token": null, + "scope": "openid", + "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ims2SE4yREtvay1rRXhqSkdCTHFnekJ5TUNuTjFSdnpFT0EtMXVrVGpleEEifQ.eyJzdWIiOiIwMHUycTVwM2FjVk9Yb1NjMDR3NSIsIm5hbWUiOiJBcnRodXIgRGVudCIsInZlciI6MSwiaXNzIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9vYXV0aDIvZGVmYXVsdCIsImF1ZCI6IjBvYTNlbjRmSU1RM2RkYzIwNHc1IiwiaWF0IjoxNjQyNTMyNTYyLCJleHAiOjE2NDI1MzYxNjIsImp0aSI6IklELmJyNFdtM29RR2RqMGZzOFNDR3JLckNrX09pQmd1dEdya2dtZGk5VU9wZTgiLCJhbXIiOlsicHdkIl0sImlkcCI6IjAwbzJxNWhtTEFFWFRuWmxoNHc1IiwicHJlZmVycmVkX3VzZXJuYW1lIjoiYXJ0aHVyLmRlbnRAZXhhbXBsZS5jb20iLCJhdXRoX3RpbWUiOjE2NDI1MzI1NjEsImF0X2hhc2giOiJXbGN3enQtczNzeE9xMFlfRFNzcGFnIn0.hMcCg_SVy6TKC7KpHRfW484p-jxxdyKf5koWESFDoaouC_uEmtJr7KzpwYYkRM5A2T7_GuQ3E9dSv1l1M9Pp1b2fVIXHiCXTj9whbx97-xyTAT5HqQY_-nk_xUIYqzNOqWCMrP2PxZ4erRl_iRhu0KyL4neIalDIbnHPopzlALn-RRBHyyU9NHGXeyMWGhEV3NLmSIxVQWiwAySKxM5GbafHLvVhK2uJxCqQG6GPU5MwxkdJe_3W2Lvefv9iUn_YJENFF54Ph8NTuJzz6ccep6haHuEMpBZny9qd1fbITxMJi9dAPEbGm9ne9ch5gO7skPHTg-KFl90eIaU-zoKK-w" +} diff --git a/Tests/AuthFoundationTests/OAuth2ClientTests.swift b/Tests/AuthFoundationTests/OAuth2ClientTests.swift index bb3932404..14b281e44 100644 --- a/Tests/AuthFoundationTests/OAuth2ClientTests.swift +++ b/Tests/AuthFoundationTests/OAuth2ClientTests.swift @@ -17,17 +17,17 @@ final class OAuth2ClientTests: XCTestCase { urlSession = URLSessionMock() client = OAuth2Client(configuration, session: urlSession) - token = Token(id: "TokenId", - issuedAt: Date(), - tokenType: "Bearer", - expiresIn: 300, - accessToken: "abcd123", - scope: "openid", - refreshToken: "refresh", - idToken: nil, - deviceSecret: nil, - context: Token.Context(configuration: self.configuration, - clientSettings: [ "client_id": "clientid", "refresh_token": "refresh" ])) + token = try! Token(id: "TokenId", + issuedAt: Date(), + tokenType: "Bearer", + expiresIn: 300, + accessToken: "abcd123", + scope: "openid", + refreshToken: "refresh", + idToken: nil, + deviceSecret: nil, + context: Token.Context(configuration: self.configuration, + clientSettings: [ "client_id": "clientid", "refresh_token": "refresh" ])) openIdConfiguration = try OpenIdConfiguration.jsonDecoder.decode( OpenIdConfiguration.self, @@ -440,17 +440,17 @@ final class OAuth2ClientTests: XCTestCase { } func testIntrospectFailed() throws { - let token = Token(id: "TokenId", - issuedAt: Date(), - tokenType: "Bearer", - expiresIn: 300, - accessToken: "abcd123", - scope: "openid", - refreshToken: nil, - idToken: nil, - deviceSecret: nil, - context: Token.Context(configuration: self.configuration, - clientSettings: [])) + let token = try! Token(id: "TokenId", + issuedAt: Date(), + tokenType: "Bearer", + expiresIn: 300, + accessToken: "abcd123", + scope: "openid", + refreshToken: nil, + idToken: nil, + deviceSecret: nil, + context: Token.Context(configuration: self.configuration, + clientSettings: [])) urlSession.expect("https://example.com/.well-known/openid-configuration", data: try data(from: .module, for: "openid-configuration", in: "MockResponses"), contentType: "application/json") diff --git a/Tests/AuthFoundationTests/TokenTests.swift b/Tests/AuthFoundationTests/TokenTests.swift index ba0efe90a..7d27f16a8 100644 --- a/Tests/AuthFoundationTests/TokenTests.swift +++ b/Tests/AuthFoundationTests/TokenTests.swift @@ -90,17 +90,17 @@ final class TokenTests: XCTestCase { } func testToken() throws { - let token = Token(id: "TokenId", - issuedAt: Date(), - tokenType: "Bearer", - expiresIn: 3600, - accessToken: "the_access_token", - scope: "openid profile offline_access", - refreshToken: "the_refresh_token", - idToken: nil, - deviceSecret: "the_device_secret", - context: Token.Context(configuration: configuration, - clientSettings: [])) + let token = try! Token(id: "TokenId", + issuedAt: Date(), + tokenType: "Bearer", + expiresIn: 3600, + accessToken: "the_access_token", + scope: "openid profile offline_access", + refreshToken: "the_refresh_token", + idToken: nil, + deviceSecret: "the_device_secret", + context: Token.Context(configuration: configuration, + clientSettings: [])) XCTAssertEqual(token.token(of: .accessToken), token.accessToken) XCTAssertEqual(token.token(of: .refreshToken), token.refreshToken) @@ -141,7 +141,7 @@ final class TokenTests: XCTestCase { XCTAssertThrowsError(try decoder.decode(Token.self, from: try data(from: .module, - for: "token-mfa_attestation", + for: "token-no_access_token", in: "MockResponses"))) } @@ -181,6 +181,70 @@ final class TokenTests: XCTestCase { XCTAssertNotEqual(token.id, Token.RefreshRequest.placeholderId) } + func testTokenFromV1Data() throws { + // Note: The following is a redacted version of the raw payload saved to + // the keychain from a version of the SDK where the V1 coding keys + // were used. This is to ensure mimgration works as expected. + let storedData = """ + {"scope":"profile offline_access openid","context":{"configuration":{"scopes":"openid profile offline_access","baseURL":"https://example.com/oauth2/default","clientId":"0oatheclientid","authentication":{"none":{}},"discoveryURL":"https://example.com/oauth2/default/.well-known/openid-configuration"},"clientSettings":{"client_id":"0oatheclientid","scope":"openid profile offline_access","redirect_uri":"com.example:/callback"}},"accessToken":"\(JWT.mockAccessToken)","tokenType":"Bearer","idToken":"\(JWT.mockIDToken)","id":"1834AF8D-BC97-4CCE-876F-300314784D5B","expiresIn":3600,"refreshToken":"refresh-kl2QWaYgyHaLkCdc6exjsowP9KUTW1ilAWC","deviceSecret":"device_lh4nMHgcUWLJIVgkcbQwnnSI2F8JMwNshLoa","issuedAt":744576826.0011461} + """ + let data = try XCTUnwrap(storedData.data(using: .utf8)) + + let token = try JSONDecoder().decode(Token.self, from: data) + XCTAssertEqual(token.id, "1834AF8D-BC97-4CCE-876F-300314784D5B") + XCTAssertEqual(token.accessToken, JWT.mockAccessToken) + XCTAssertEqual(token.idToken?.rawValue, JWT.mockIDToken) + XCTAssertEqual(token.scope, "profile offline_access openid") + XCTAssertEqual(token.expiresIn, 3600) + XCTAssertEqual(token.refreshToken, "refresh-kl2QWaYgyHaLkCdc6exjsowP9KUTW1ilAWC") + XCTAssertEqual(token.deviceSecret, "device_lh4nMHgcUWLJIVgkcbQwnnSI2F8JMwNshLoa") + XCTAssertEqual(token.issuedAt?.timeIntervalSinceReferenceDate, 744576826.0011461) + XCTAssertEqual(token.context.configuration.scopes, "openid profile offline_access") + XCTAssertEqual(token.context, .init(configuration: .init(baseURL: try XCTUnwrap(URL(string: "https://example.com/oauth2/default")), + clientId: "0oatheclientid", + scopes: "openid profile offline_access", + authentication: .none), + clientSettings: [ + "client_id": "0oatheclientid", + "scope": "openid profile offline_access", + "redirect_uri":"com.example:/callback", + ])) + XCTAssertEqual(token.jsonPayload.jsonValue, try JSON([ + "scope": "profile offline_access openid", + "access_token": JWT.mockAccessToken, + "token_type": "Bearer", + "id_token": JWT.mockIDToken, + "expires_in": 3600, + "refresh_token": "refresh-kl2QWaYgyHaLkCdc6exjsowP9KUTW1ilAWC", + "device_secret":"device_lh4nMHgcUWLJIVgkcbQwnnSI2F8JMwNshLoa", + ])) + } + + func testTokenClaims() throws { + var token: Token! + + token = try! Token(id: "TokenId", + issuedAt: Date(), + tokenType: "Bearer", + expiresIn: 3600, + accessToken: "the_access_token", + scope: "openid profile offline_access", + refreshToken: "the_refresh_token", + idToken: nil, + deviceSecret: "the_device_secret", + context: Token.Context(configuration: configuration, + clientSettings: [])) + XCTAssertEqual(token.allClaims.sorted(), [ + "expires_in", + "token_type", + "access_token", + "scope", + "refresh_token", + "device_secret", + ].sorted()) + XCTAssertEqual(token[.accessToken], "the_access_token") + } + #if swift(>=5.5.1) @available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6, *) func testTokenFromRefreshTokenAsync() async throws { diff --git a/Tests/AuthFoundationTests/UserDefaultsTokenStorageTests.swift b/Tests/AuthFoundationTests/UserDefaultsTokenStorageTests.swift index f35b2e4b3..ae9a04ff2 100644 --- a/Tests/AuthFoundationTests/UserDefaultsTokenStorageTests.swift +++ b/Tests/AuthFoundationTests/UserDefaultsTokenStorageTests.swift @@ -19,33 +19,33 @@ final class UserDefaultTokenStorageTests: XCTestCase { var userDefaults: UserDefaults! var storage: UserDefaultsTokenStorage! - let token = Token(id: "TokenId", - issuedAt: Date(), - tokenType: "Bearer", - expiresIn: 300, - accessToken: "abcd123", - scope: "openid", - refreshToken: nil, - idToken: nil, - deviceSecret: nil, - context: Token.Context(configuration: .init(baseURL: URL(string: "https://example.com")!, - clientId: "clientid", - scopes: "openid"), - clientSettings: nil)) - - let newToken = Token(id: "TokenId2", - issuedAt: Date(), - tokenType: "Bearer", - expiresIn: 300, - accessToken: "zxy987", - scope: "openid", - refreshToken: nil, - idToken: nil, - deviceSecret: nil, - context: Token.Context(configuration: .init(baseURL: URL(string: "https://example.com")!, - clientId: "clientid", - scopes: "openid"), - clientSettings: nil)) + let token = try! Token(id: "TokenId", + issuedAt: Date(), + tokenType: "Bearer", + expiresIn: 300, + accessToken: "abcd123", + scope: "openid", + refreshToken: nil, + idToken: nil, + deviceSecret: nil, + context: Token.Context(configuration: .init(baseURL: URL(string: "https://example.com")!, + clientId: "clientid", + scopes: "openid"), + clientSettings: nil)) + + let newToken = try! Token(id: "TokenId2", + issuedAt: Date(), + tokenType: "Bearer", + expiresIn: 300, + accessToken: "zxy987", + scope: "openid", + refreshToken: nil, + idToken: nil, + deviceSecret: nil, + context: Token.Context(configuration: .init(baseURL: URL(string: "https://example.com")!, + clientId: "clientid", + scopes: "openid"), + clientSettings: nil)) override func setUpWithError() throws { userDefaults = UserDefaults(suiteName: name) diff --git a/Tests/TestCommon/MockToken.swift b/Tests/TestCommon/MockToken.swift index 3709920f1..6b80b04fa 100644 --- a/Tests/TestCommon/MockToken.swift +++ b/Tests/TestCommon/MockToken.swift @@ -35,17 +35,17 @@ extension Token { { let clientSettings = [ "client_id": mockConfiguration.clientId ] - return Token(id: id, - issuedAt: Date(timeIntervalSinceNow: -issuedOffset), - tokenType: "Bearer", - expiresIn: expiresIn, - accessToken: JWT.mockAccessToken, - scope: "openid", - refreshToken: refreshToken, - idToken: try? JWT(JWT.mockIDToken), - deviceSecret: deviceSecret, - context: .init(configuration: mockConfiguration, - clientSettings: clientSettings)) + return try! Token(id: id, + issuedAt: Date(timeIntervalSinceNow: -issuedOffset), + tokenType: "Bearer", + expiresIn: expiresIn, + accessToken: JWT.mockAccessToken, + scope: "openid", + refreshToken: refreshToken, + idToken: try? JWT(JWT.mockIDToken), + deviceSecret: deviceSecret, + context: .init(configuration: mockConfiguration, + clientSettings: clientSettings)) } static func token(with options: [MockOptions] = []) -> Token { @@ -68,19 +68,19 @@ extension Token { idToken = try! JWT(JWT.mockIDToken) } - return Token(id: "TokenId", - issuedAt: Date(), - tokenType: "Bearer", - expiresIn: 300, - accessToken: JWT.mockAccessToken, - scope: scopes, - refreshToken: refreshToken, - idToken: idToken, - deviceSecret: deviceSecret, - context: Token.Context(configuration: .init(baseURL: URL(string: "https://example.com/oauth2/default")!, - clientId: "clientid", - scopes: scopes), - clientSettings: [ "client_id": "clientid" ])) + return try! Token(id: "TokenId", + issuedAt: Date(), + tokenType: "Bearer", + expiresIn: 300, + accessToken: JWT.mockAccessToken, + scope: scopes, + refreshToken: refreshToken, + idToken: idToken, + deviceSecret: deviceSecret, + context: Token.Context(configuration: .init(baseURL: URL(string: "https://example.com/oauth2/default")!, + clientId: "clientid", + scopes: scopes), + clientSettings: [ "client_id": "clientid" ])) } }