From 99d57ced9569023f2156b47218f60f358dfecd86 Mon Sep 17 00:00:00 2001 From: Mike Nachbaur <74688448+mikenachbaur-okta@users.noreply.github.com> Date: Thu, 28 Mar 2024 11:22:34 -0700 Subject: [PATCH] Make OpenIdConfiguration adaptable to a variable number of claims, while making claims themselves generic (#182) * Make OpenIdConfiguration adaptable to a variable number of claims, while making claims themselves generic * Lint and test updates * Ensure NS* underlying types conform to ClaimConvertable as well * Added / updated docs, and some missing functions --- .../AuthFoundation.docc/AuthFoundation.md | 15 ++- .../AuthFoundation.docc/WorkingWithClaims.md | 76 ++++++++++++ .../JWT/Enums/AuthenticationMethod.swift | 79 ++++++++++++ .../JWT/{ => Enums}/JWK+Enums.swift | 0 .../JWT/{Claim.swift => Enums/JWTClaim.swift} | 73 +++--------- .../JWT/Extensions/Claim+Extensions.swift | 18 ++- .../ClaimConvertable+Extensions.swift | 64 ++++++++++ Sources/AuthFoundation/JWT/JWT.swift | 24 +--- .../AuthFoundation/JWT/Protocols/Claim.swift | 112 ++++++++++++++++++ .../JWT/{ => Protocols}/ClaimContainer.swift | 22 +--- .../JWT/Protocols/ClaimConvertable.swift | 29 +++++ .../JWT/{ => Protocols}/JWKValidator.swift | 0 .../AuthFoundation/OAuth2/OAuth2Client.swift | 30 +++-- .../Requests/Token+Requests.swift | 20 +++- .../AuthFoundation/Responses/GrantType.swift | 2 +- .../Responses/OpenIdConfiguration.swift | 74 +++++++++--- .../Responses/OpenIdProviderMetadata.swift | 69 +++++++++++ .../AuthFoundation/Responses/TokenInfo.swift | 4 +- .../AuthFoundation/Responses/UserInfo.swift | 4 +- .../Token Management/Token+Metadata.swift | 4 +- .../Token Management/Token.swift | 2 + .../Credential+Extensions.swift | 11 ++ Tests/AuthFoundationTests/ClaimTests.swift | 79 ++++++++++++ Tests/AuthFoundationTests/JWTTests.swift | 2 +- .../OAuth2ClientTests.swift | 10 +- .../OpenIDConfigurationTests.swift | 34 +++++- 26 files changed, 720 insertions(+), 137 deletions(-) create mode 100644 Sources/AuthFoundation/AuthFoundation.docc/WorkingWithClaims.md create mode 100644 Sources/AuthFoundation/JWT/Enums/AuthenticationMethod.swift rename Sources/AuthFoundation/JWT/{ => Enums}/JWK+Enums.swift (100%) rename Sources/AuthFoundation/JWT/{Claim.swift => Enums/JWTClaim.swift} (81%) create mode 100644 Sources/AuthFoundation/JWT/Extensions/ClaimConvertable+Extensions.swift create mode 100644 Sources/AuthFoundation/JWT/Protocols/Claim.swift rename Sources/AuthFoundation/JWT/{ => Protocols}/ClaimContainer.swift (72%) create mode 100644 Sources/AuthFoundation/JWT/Protocols/ClaimConvertable.swift rename Sources/AuthFoundation/JWT/{ => Protocols}/JWKValidator.swift (100%) create mode 100644 Sources/AuthFoundation/Responses/OpenIdProviderMetadata.swift create mode 100644 Tests/AuthFoundationTests/ClaimTests.swift diff --git a/Sources/AuthFoundation/AuthFoundation.docc/AuthFoundation.md b/Sources/AuthFoundation/AuthFoundation.docc/AuthFoundation.md index 9dc500554..626fb89d1 100644 --- a/Sources/AuthFoundation/AuthFoundation.docc/AuthFoundation.md +++ b/Sources/AuthFoundation/AuthFoundation.docc/AuthFoundation.md @@ -30,6 +30,7 @@ You can use AuthFoundation when you want to: - ``OAuth2Client`` - ``OAuth2ClientDelegate`` - ``OpenIdConfiguration`` +- ``AuthenticationMethod`` - ``AuthenticationFlow`` - ``AuthenticationDelegate`` - ``OAuth2TokenRequest`` @@ -41,14 +42,18 @@ You can use AuthFoundation when you want to: - ``JWT`` - ``JWK`` - ``JWKS`` -- ``Claim`` +- ``JWTClaim`` - ``HasClaims`` -- ``ClaimContainer`` +- ``JSONClaimContainer`` +- ``ClaimConvertable`` +- ``IsClaim`` - ``Expires`` ### Security - ``Keychain`` +- ``KeychainAuthenticationContext`` +- ``TokenAuthenticationContext`` ### Customizations @@ -62,6 +67,7 @@ You can use AuthFoundation when you want to: - ``JWKValidator`` - ``TokenHashValidator`` - ``IDTokenValidator`` +- ``IDTokenValidatorContext`` ### Networking @@ -74,6 +80,9 @@ You can use AuthFoundation when you want to: - ``APIRequestArgument`` - ``APIRequestMethod`` - ``APIResponse`` +- ``APIResponseResult`` +- ``APIRateLimit`` +- ``APIRetry`` - ``APIAuthorization`` - ``APIParsingContext`` - ``OAuth2APIRequest`` @@ -91,11 +100,13 @@ You can use AuthFoundation when you want to: - ``JWTError`` - ``KeychainError`` - ``AuthenticationError`` +- ``JSONValueError`` ### Migration and versioning - ``SDKVersion`` - ``SDKVersionMigrator`` +- ``Version`` ### Internals and mocking diff --git a/Sources/AuthFoundation/AuthFoundation.docc/WorkingWithClaims.md b/Sources/AuthFoundation/AuthFoundation.docc/WorkingWithClaims.md new file mode 100644 index 000000000..9f5ae3d73 --- /dev/null +++ b/Sources/AuthFoundation/AuthFoundation.docc/WorkingWithClaims.md @@ -0,0 +1,76 @@ +# Working with Claims + +Using Claims on the various types included in OIDC and AuthFoundation. + +## Overview + +OpenID Connect (OIDC) uses claims to describe individual pieces of information, like email or name, packaged into responses from the server. ID Tokens in particular contain claims, packaged within a JSON Web Token (``JWT``). A variety of other OIDC capabilities also supply information using claims, such as the OpenID Configuration (``OpenIdConfiguration``) metadata returned from the server, when [introspecting a token](``Credential/introspect(_:)``). + +Since claims are a common characteristic of authentication, this SDK provides features that improves the developer experience (DX) to simplify how this information is used, and to make these capabilities consistent across areas of the toolchain. + +## Types that have claims + +A variety of types contain claims, which are identified by types that conform to the ``HasClaims`` protocol. This protocol provides common access patterns for using claims, and conveniences for simplfying how you can access them. + +Some of these types include: + +* ``JWT`` +* ``UserInfo`` +* ``TokenInfo`` +* ``OpenIdConfiguration`` +* ``Token/Metadata`` + +To better understand how these types can be used, it's best to work with an example, starting with ``JWT``. + +## Examples of using Claims + +When a user signs in they are issued a ``Token/idToken`` which is returned as a JSON Web Token ``JWT``. This token contains information about the user, and other important values which could be useful to your application. For example, you may wish to retrieve the user's name and "subject" (their user identifier) to display within your interface. The ``HasClaims`` protocol makes this easy by providing several ways to extract information. + +### Keyed Subscripting, using strings + +If you know the string identifier for the claim, you can use that as a subscript key on the relevant object. + +```swift +if let identifier = token.idToken?["sub"] { + Text("Username: \(identifier)") +} +``` + +This can be useful, especially when your application uses custom claims supplied from the authorization server, but when using standard claim values, it can be more convenient to use enums. + +### Keyed Subscripting, using claim enum values + +Enum values are often more convenient to use since code auto-completion and compile-time warnings can ensure consistency when working with these values. + +```swift +if let identifier = token.idToken?[.subject] { + Text("Username: \(identifier)") +} +``` + +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. + +### 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``. + +```swift +if let authTime = token.idToken?.authTime, + authTime.timeIntervalSinceNow < 3600 { + // The user authenticated more than one hour ago +} +``` + +### Enums and arrays of converted values + +Some types conform to a special protocol called ``ClaimConvertable``, which enables concrete types to be convertable from the raw claim values supplied by the authorization server. This can make interacting with claims easier and more developer-friendly. + +One example of this type is the ``HasClaims/authenticationMethods`` property. The ``JWTClaim/authMethodsReference`` claim returns an array of the methods a user used to authenticate their account. The values for this claim can be represented by the ``AuthenticationMethod`` enum, so instead of performing string comparisons, you can reference this convenience property to work with the authentication methods reference as an array of enums. + +```swift +if let authenticationMethods = token.idToken?.authenticationMethods, + authenticationMethods.contains(.multipleFactor) +{ + // The user authenticated using some multifactor step +} +``` diff --git a/Sources/AuthFoundation/JWT/Enums/AuthenticationMethod.swift b/Sources/AuthFoundation/JWT/Enums/AuthenticationMethod.swift new file mode 100644 index 000000000..1e0ce3c50 --- /dev/null +++ b/Sources/AuthFoundation/JWT/Enums/AuthenticationMethod.swift @@ -0,0 +1,79 @@ +// +// 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 + +/// Defines the possible values for Authentication Methods, used within ``JWT/authenticationMethods``. +public enum AuthenticationMethod: String, ClaimConvertable, IsClaim { + /// Facial recognition + case facialRecognition = "face" + + /// Fingerprint biometric + case fingerprintBiometric = "fpt" + + /// Geolocation + case geolocation = "geo" + + /// Proof-of-possession of a hardware-secured key + case proofOfPossessionHardware = "hwk" + + /// Iris scan biometric + case irisScanBiometric = "iris" + + /// Knowledge-based authentication + case knowledgeBased = "kba" + + /// Multiple-channel authentication + case multipleChannel = "mca" + + /// Multiple-factor authentication + case multipleFactor = "mfa" + + /// One-time password + case oneTimePassword = "otp" + + /// Personal Identification Number or pattern + case pin + + /// Proof-of-possession of a key + case proofOfPossession = "pop" + + /// Password-based authentication + case passwordBased = "pwd" + + /// Risk-based authentication + case riskBased = "rba" + + /// Retina scan biometric + case retinaScanBiometric = "retina" + + /// Smart card + case smartCard = "sc" + + /// Confirmation using SMS + case smsConfirmation = "sms" + + /// Proof-of-possession of a software-secured key + case proofOfPossessionSoftware = "swk" + + /// Confirmation by telephone call + case telephoneConfirmation = "tel" + + /// User presence test + case userPresence = "user" + + /// Voice biometric + case voiceBiometric = "vbm" + + /// Windows integrated authentication + case windowsIntegrated = "wia" +} diff --git a/Sources/AuthFoundation/JWT/JWK+Enums.swift b/Sources/AuthFoundation/JWT/Enums/JWK+Enums.swift similarity index 100% rename from Sources/AuthFoundation/JWT/JWK+Enums.swift rename to Sources/AuthFoundation/JWT/Enums/JWK+Enums.swift diff --git a/Sources/AuthFoundation/JWT/Claim.swift b/Sources/AuthFoundation/JWT/Enums/JWTClaim.swift similarity index 81% rename from Sources/AuthFoundation/JWT/Claim.swift rename to Sources/AuthFoundation/JWT/Enums/JWTClaim.swift index 0eb00276c..0cabfca55 100644 --- a/Sources/AuthFoundation/JWT/Claim.swift +++ b/Sources/AuthFoundation/JWT/Enums/JWTClaim.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2021-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// 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. @@ -12,8 +12,11 @@ import Foundation +@available(*, deprecated, renamed: "JWTClaim") +public typealias Claim = JWTClaim + /// List of registered and public claims. -public enum Claim: Codable { +public enum JWTClaim: Codable, IsClaim { /// Issuer case issuer @@ -229,62 +232,24 @@ public enum Claim: Codable { /// Token introspection response case tokenIntrospection - - /// Custom claim with the given name - case custom(_ name: String) -} - -/// 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. -public protocol HasClaims { - /// 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: [Claim] { get } - /// Returns the collection of custom claims this object contains. - /// - /// Unlike the ``claims`` property, this returns values as strings. - var customClaims: [String] { get } + /// Indicates whether the transaction is on a nonce-supported platform. If you sent a nonce in the authorization request but do not see the nonce claim in the ID token, check this claim to determine how to proceed. Used predominantly by Sign In With Apple. + case nonceSupported - /// All claims, across both standard ``claims`` and ``customClaims``. - var allClaims: [String] { get } + /// Indicates the liklihood of whether or not this appears to be a real user. Used predominantly by Sign In With Apple. + case realUserStatus - /// Raw paylaod of claims, as a dictionary representation. - var payload: [String: Any] { get } + /// Indicates if the email address provided is a proxied address. Used predominantly by Sign In With Apple. + case isPrivateEmail - /// Return the given claim's value. - subscript(_ claim: Claim) -> T? { get } + /// Identifier used when transfering subjects. Used predominantly by Sign In With Apple. + case transferSubject - /// Return the given claim's value. - subscript(_ claim: String) -> T? { get } - - /// Return the value of the requested claim, for the given type. - func value(_ type: T.Type, for key: String) -> T? + /// Custom claim with the given name + case custom(_ name: String) } -public extension HasClaims { - subscript(_ claim: Claim) -> T? { - self[claim.rawValue] - } - - subscript(_ claim: String) -> T? { - if T.self == Date.self { - guard let time = value(Int.self, for: claim) else { return nil } - return Date(timeIntervalSince1970: TimeInterval(time)) as? T - } else { - return value(T.self, for: claim) - } - } - - var allClaims: [String] { - Array([ - claims.map(\.rawValue), - customClaims - ].joined()) - } - +public extension HasClaims where ClaimType == JWTClaim { /// The subject of the resource, if available. var subject: String? { self[.subject] } @@ -392,12 +357,12 @@ public extension HasClaims { /// Returns the Authentication Context Class Reference for this token. var authenticationClass: String? { self[.authContextClassReference] } - /// The list of authentication methods included in this token. + /// The list of authentication methods included in this token, which defines the list of methods that were used to authenticate the user. /// /// ```swift - /// if jwt.authenticationMethods?.contains("mfa") { + /// if jwt.authenticationMethods?.contains(.multiFactor) { /// // The user authenticated with an MFA factor. /// } /// ``` - var authenticationMethods: [String]? { self[.authMethodsReference] } + var authenticationMethods: [AuthenticationMethod]? { arrayValue(AuthenticationMethod.self, for: .authMethodsReference) } } diff --git a/Sources/AuthFoundation/JWT/Extensions/Claim+Extensions.swift b/Sources/AuthFoundation/JWT/Extensions/Claim+Extensions.swift index f8a51624d..6a998f9a4 100644 --- a/Sources/AuthFoundation/JWT/Extensions/Claim+Extensions.swift +++ b/Sources/AuthFoundation/JWT/Extensions/Claim+Extensions.swift @@ -14,7 +14,7 @@ import Foundation // swiftlint:disable function_body_length // swiftlint:disable cyclomatic_complexity -extension Claim: RawRepresentable, Equatable { +extension JWTClaim: RawRepresentable, Equatable { public typealias RawValue = String public init?(rawValue: String) { @@ -165,6 +165,14 @@ extension Claim: RawRepresentable, Equatable { self = .entitlements case "token_introspection": self = .tokenIntrospection + case "nonce_supported": + self = .nonceSupported + case "real_user_status": + self = .realUserStatus + case "is_private_email": + self = .isPrivateEmail + case "transfer_sub": + self = .transferSubject default: self = .custom(rawValue) } @@ -318,6 +326,14 @@ extension Claim: RawRepresentable, Equatable { return "entitlements" case .tokenIntrospection: return "token_introspection" + case .nonceSupported: + return "nonce_supported" + case .realUserStatus: + return "real_user_status" + case .isPrivateEmail: + return "is_private_email" + case .transferSubject: + return "transfer_sub" case .custom(let name): return name } diff --git a/Sources/AuthFoundation/JWT/Extensions/ClaimConvertable+Extensions.swift b/Sources/AuthFoundation/JWT/Extensions/ClaimConvertable+Extensions.swift new file mode 100644 index 000000000..be3b1833a --- /dev/null +++ b/Sources/AuthFoundation/JWT/Extensions/ClaimConvertable+Extensions.swift @@ -0,0 +1,64 @@ +// +// 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 String: ClaimConvertable {} +extension Bool: ClaimConvertable {} +extension Int: ClaimConvertable {} +extension Double: ClaimConvertable {} +extension Float: ClaimConvertable {} +extension Array: ClaimConvertable {} +extension Dictionary: ClaimConvertable {} +extension URL: ClaimConvertable {} +extension Date: ClaimConvertable {} +extension JWTClaim: ClaimConvertable {} +extension GrantType: ClaimConvertable {} +extension NSString: ClaimConvertable {} +extension NSNumber: ClaimConvertable {} + +extension ClaimConvertable where Self == Date { + public static func claim(_ claim: String, + in type: any HasClaims, + from value: Any?) -> Self? + { + if let time = value as? Int { + return Date(timeIntervalSince1970: TimeInterval(time)) + } + + if let time = value as? String { + return ISO8601DateFormatter().date(from: time) + } + + return nil + } +} + +extension ClaimConvertable where Self == URL { + public static func claim(_ claim: String, + in type: any HasClaims, + 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) + } +} diff --git a/Sources/AuthFoundation/JWT/JWT.swift b/Sources/AuthFoundation/JWT/JWT.swift index 0bbcfe256..64b6f7632 100644 --- a/Sources/AuthFoundation/JWT/JWT.swift +++ b/Sources/AuthFoundation/JWT/JWT.swift @@ -14,6 +14,7 @@ import Foundation /// Represents the contents of a JWT token, providing access to its payload contents. public struct JWT: RawRepresentable, Codable, HasClaims, Expires { + public typealias ClaimType = JWTClaim public typealias RawValue = String /// The raw string representation of this JWT token. @@ -39,32 +40,11 @@ public struct JWT: RawRepresentable, Codable, HasClaims, Expires { /// The array of scopes this token is valid for. public var scope: [String]? { self[.scope] ?? self["scp"] } - /// The array of authentication methods. - /// - /// The ``Claim/authMethodsReference`` claim (or `amr` in string form) defines the list of methods that were used to authenticate the user. - public var authenticationMethods: [String]? { self[.authMethodsReference] } - /// The authentication context class reference. /// - /// The ``Claim/authContextClassReference`` claim (or `acr` in string form) defines a special authentication context reference which indicates additional policy choices requested when authenticating a user. + /// The ``JWTClaim/authContextClassReference`` claim (or `acr` in string form) defines a special authentication context reference which indicates additional policy choices requested when authenticating a user. public var authenticationContext: String? { self[.authContextClassReference] } - /// The list of standard claims contained within this JWT token. - public var claims: [Claim] { - payload.keys.compactMap { Claim(rawValue: $0) } - } - - /// The list of custom claims contained within this JWT token. - public var customClaims: [String] { - payload.keys.filter { Claim(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. - public func value(_ type: T.Type, for key: String) -> T? { - payload[key] as? T - } - /// JWT header information describing the contents of the token. public struct Header: Decodable { /// The ID of the key used to sign this JWT token. diff --git a/Sources/AuthFoundation/JWT/Protocols/Claim.swift b/Sources/AuthFoundation/JWT/Protocols/Claim.swift new file mode 100644 index 000000000..7f409bcc5 --- /dev/null +++ b/Sources/AuthFoundation/JWT/Protocols/Claim.swift @@ -0,0 +1,112 @@ +// +// Copyright (c) 2021-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 + +/// 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) +} + +/// 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. +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 paylaod of claims, as a dictionary representation. + var payload: [String: Any] { get } +} + +public extension HasClaims { + /// The list of standard claims contained within this JWT token. + var claims: [ClaimType] { + payload.keys.compactMap { ClaimType(rawValue: $0) } + } + + /// The list of custom claims contained within this JWT token. + 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([ + claims.map(\.rawValue), + customClaims + ].joined()) + } +} diff --git a/Sources/AuthFoundation/JWT/ClaimContainer.swift b/Sources/AuthFoundation/JWT/Protocols/ClaimContainer.swift similarity index 72% rename from Sources/AuthFoundation/JWT/ClaimContainer.swift rename to Sources/AuthFoundation/JWT/Protocols/ClaimContainer.swift index 60393c3a3..bdf7fd17a 100644 --- a/Sources/AuthFoundation/JWT/ClaimContainer.swift +++ b/Sources/AuthFoundation/JWT/Protocols/ClaimContainer.swift @@ -15,11 +15,9 @@ import Foundation /// Protocol used to define shared behavior when an object can contain claims. /// /// > Note: This does not apply to JWT, which while it contains claims, it has a different format which includes headers and signatures. -public protocol ClaimContainer: JSONDecodable { - var payload: [String: Any] { get } -} +public protocol JSONClaimContainer: HasClaims, JSONDecodable {} -extension ClaimContainer { +extension JSONClaimContainer { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: JSONCodingKeys.self) try payload @@ -41,20 +39,4 @@ extension ClaimContainer { } } } - - /// Returns the list of all claims contained within this ``UserInfo``. - public var claims: [Claim] { - payload.keys.compactMap { Claim(rawValue: $0) } - } - - /// Returns the list of custom claims this instance might contain. - public var customClaims: [String] { - payload.keys.filter { Claim(rawValue: $0) == nil } - } - - /// Returns the value for the supplied key, of the expected type. - /// - Returns: Value, or `nil` if the key doesn't exist, or is of a different value. - public func value(_ type: T.Type, for key: String) -> T? { - payload[key] as? T - } } diff --git a/Sources/AuthFoundation/JWT/Protocols/ClaimConvertable.swift b/Sources/AuthFoundation/JWT/Protocols/ClaimConvertable.swift new file mode 100644 index 000000000..e15a0476f --- /dev/null +++ b/Sources/AuthFoundation/JWT/Protocols/ClaimConvertable.swift @@ -0,0 +1,29 @@ +// +// 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 + +/// 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? +} + +extension ClaimConvertable { + public static func claim(_ claim: String, + in type: any HasClaims, + from value: Any?) -> Self? + { + value as? Self + } +} diff --git a/Sources/AuthFoundation/JWT/JWKValidator.swift b/Sources/AuthFoundation/JWT/Protocols/JWKValidator.swift similarity index 100% rename from Sources/AuthFoundation/JWT/JWKValidator.swift rename to Sources/AuthFoundation/JWT/Protocols/JWKValidator.swift diff --git a/Sources/AuthFoundation/OAuth2/OAuth2Client.swift b/Sources/AuthFoundation/OAuth2/OAuth2Client.swift index 14fd8d3a1..a7c83cfe9 100644 --- a/Sources/AuthFoundation/OAuth2/OAuth2Client.swift +++ b/Sources/AuthFoundation/OAuth2/OAuth2Client.swift @@ -263,18 +263,26 @@ public final class OAuth2Client { openIdConfiguration { result in switch result { case .success(let configuration): - let request = Token.RevokeRequest(openIdConfiguration: configuration, - clientAuthentication: self.configuration.authentication, - token: tokenString, - hint: tokenType, - configuration: clientSettings) - request.send(to: self) { result in - switch result { - case .success: - completion(.success(())) - case .failure(let error): - completion(.failure(.network(error: error))) + do { + let request = try Token.RevokeRequest(openIdConfiguration: configuration, + clientAuthentication: self.configuration.authentication, + token: tokenString, + hint: tokenType, + configuration: clientSettings) + request.send(to: self) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(.network(error: error))) + } } + } catch let error as OAuth2Error { + completion(.failure(error)) + return + } catch { + completion(.failure(.error(error))) + return } case .failure(let error): completion(.failure(error)) diff --git a/Sources/AuthFoundation/Requests/Token+Requests.swift b/Sources/AuthFoundation/Requests/Token+Requests.swift index efd7641cc..568255fe6 100644 --- a/Sources/AuthFoundation/Requests/Token+Requests.swift +++ b/Sources/AuthFoundation/Requests/Token+Requests.swift @@ -15,9 +15,28 @@ extension Token { struct RevokeRequest { let openIdConfiguration: OpenIdConfiguration let clientAuthentication: OAuth2Client.ClientAuthentication + let url: URL let token: String let hint: Token.Kind? let configuration: [String: String] + + init(openIdConfiguration: OpenIdConfiguration, + clientAuthentication: OAuth2Client.ClientAuthentication, + token: String, + hint: Token.Kind?, + configuration: [String: String]) throws + { + self.openIdConfiguration = openIdConfiguration + self.clientAuthentication = clientAuthentication + self.token = token + self.hint = hint + self.configuration = configuration + + guard let url = openIdConfiguration.revocationEndpoint else { + throw OAuth2Error.missingOpenIdConfiguration(attribute: "revocation_endpoint") + } + self.url = url + } } struct RefreshRequest { @@ -69,7 +88,6 @@ extension Token.RevokeRequest: OAuth2APIRequest, APIRequestBody { typealias ResponseType = Empty var httpMethod: APIRequestMethod { .post } - var url: URL { openIdConfiguration.revocationEndpoint } var contentType: APIContentType? { .formEncoded } var acceptsType: APIContentType? { .json } var bodyParameters: [String: Any]? { diff --git a/Sources/AuthFoundation/Responses/GrantType.swift b/Sources/AuthFoundation/Responses/GrantType.swift index 1bc68888b..0fe9c9868 100644 --- a/Sources/AuthFoundation/Responses/GrantType.swift +++ b/Sources/AuthFoundation/Responses/GrantType.swift @@ -12,7 +12,7 @@ import Foundation -public enum GrantType: Codable, Hashable { +public enum GrantType: Codable, Hashable, IsClaim { case authorizationCode case implicit case refreshToken diff --git a/Sources/AuthFoundation/Responses/OpenIdConfiguration.swift b/Sources/AuthFoundation/Responses/OpenIdConfiguration.swift index 65bcded90..ad8b85370 100644 --- a/Sources/AuthFoundation/Responses/OpenIdConfiguration.swift +++ b/Sources/AuthFoundation/Responses/OpenIdConfiguration.swift @@ -14,28 +14,74 @@ import Foundation /// Describes the configuration of an OpenID server. /// -/// The values exposed from this configuration are typically used during authentication, or when querying a server for its capabilities. -public struct OpenIdConfiguration: Codable, JSONDecodable { - public let authorizationEndpoint: URL - public let endSessionEndpoint: URL? - public let introspectionEndpoint: URL? - public let deviceAuthorizationEndpoint: URL? +/// The values exposed from this configuration are typically used during authentication, or when querying a server for its capabilities. This type uses ``HasClaims`` to represent the various provider metadata (represented as ``OpenIdConfiguration/ProviderMetadata``) for returning the full contents of the server's configuration. For more information, please refer to the documentation. +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) + + let container = try decoder.container(keyedBy: JSONCodingKeys.self) + payload = try container.decode([String: Any].self) + } + + /// The issuer URL for this OpenID provider. public let issuer: URL - public let jwksUri: URL - public let registrationEndpoint: URL? - public let revocationEndpoint: URL + + /// The URL for this OpenID Provider's authorization endpoint. + public let authorizationEndpoint: URL + + /// The URL for this OpenID Provider's token endpoint. public let tokenEndpoint: URL - public let userinfoEndpoint: URL? - public let scopesSupported: [String]? + + /// The URL for this OpenID Provider's JWKS endpoint. + public let jwksUri: URL + + /// The list of supported response types for this OpenID Provider. public let responseTypesSupported: [String] - public let responseModesSupported: [String]? - public let claimsSupported: [Claim] - public let grantTypesSupported: [GrantType]? + + /// The list of supported subject types for this OpenID Provider. public let subjectTypesSupported: [String] + + /// 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 + case tokenEndpoint + case jwksUri + case responseTypesSupported + case subjectTypesSupported + case idTokenSigningAlgValuesSupported + } +} + +extension OpenIdConfiguration { + public var endSessionEndpoint: URL? { self[.endSessionEndpoint] } + public var introspectionEndpoint: URL? { self[.introspectionEndpoint] } + public var deviceAuthorizationEndpoint: URL? { self[.deviceAuthorizationEndpoint] } + public var registrationEndpoint: URL? { self[.registrationEndpoint] } + public var revocationEndpoint: URL? { self[.revocationEndpoint] } + 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) } } diff --git a/Sources/AuthFoundation/Responses/OpenIdProviderMetadata.swift b/Sources/AuthFoundation/Responses/OpenIdProviderMetadata.swift new file mode 100644 index 000000000..6a45eb31a --- /dev/null +++ b/Sources/AuthFoundation/Responses/OpenIdProviderMetadata.swift @@ -0,0 +1,69 @@ +// +// 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 OpenIdConfiguration { + /// Defines the metadata claims available within an ``OpenIdConfiguration``. + public enum ProviderMetadata: String, Codable, IsClaim, CodingKey { + // 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 + + // 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 + } +} diff --git a/Sources/AuthFoundation/Responses/TokenInfo.swift b/Sources/AuthFoundation/Responses/TokenInfo.swift index 940f7cefe..8a5e20832 100644 --- a/Sources/AuthFoundation/Responses/TokenInfo.swift +++ b/Sources/AuthFoundation/Responses/TokenInfo.swift @@ -17,7 +17,9 @@ 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``. -public struct TokenInfo: Codable, HasClaims, ClaimContainer { +public struct TokenInfo: Codable, JSONClaimContainer { + public typealias ClaimType = JWTClaim + public let payload: [String: Any] public init(_ info: [String: Any]) { diff --git a/Sources/AuthFoundation/Responses/UserInfo.swift b/Sources/AuthFoundation/Responses/UserInfo.swift index 1193d9806..65617800c 100644 --- a/Sources/AuthFoundation/Responses/UserInfo.swift +++ b/Sources/AuthFoundation/Responses/UserInfo.swift @@ -17,7 +17,9 @@ 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``. -public struct UserInfo: Codable, HasClaims, ClaimContainer { +public struct UserInfo: Codable, JSONClaimContainer { + public typealias ClaimType = JWTClaim + public let payload: [String: Any] public init(_ info: [String: Any]) { diff --git a/Sources/AuthFoundation/Token Management/Token+Metadata.swift b/Sources/AuthFoundation/Token Management/Token+Metadata.swift index 3d57557f4..b5178c761 100644 --- a/Sources/AuthFoundation/Token Management/Token+Metadata.swift +++ b/Sources/AuthFoundation/Token Management/Token+Metadata.swift @@ -16,7 +16,9 @@ extension Token { /// Describes the metadata associated with a token. /// /// This is used when storing tags and claims associated with tokens, as well as through the ``Credential/find(where:prompt:authenticationContext:)`` method. - public struct Metadata: HasClaims, ClaimContainer { + public struct Metadata: JSONClaimContainer { + public typealias ClaimType = JWTClaim + /// The unique ID for the token. public let id: String diff --git a/Sources/AuthFoundation/Token Management/Token.swift b/Sources/AuthFoundation/Token Management/Token.swift index b555be372..7db3d762f 100644 --- a/Sources/AuthFoundation/Token Management/Token.swift +++ b/Sources/AuthFoundation/Token Management/Token.swift @@ -45,6 +45,8 @@ public final class Token: Codable, Equatable, Hashable, Expires { public let refreshToken: String? /// The ID token, if requested. + /// + /// For more information on working with an ID token, see the documentation. public let idToken: JWT? /// Defines the context this token was issued from. diff --git a/Sources/AuthFoundation/User Management/Credential+Extensions.swift b/Sources/AuthFoundation/User Management/Credential+Extensions.swift index c0ae85a73..864752557 100644 --- a/Sources/AuthFoundation/User Management/Credential+Extensions.swift +++ b/Sources/AuthFoundation/User Management/Credential+Extensions.swift @@ -99,5 +99,16 @@ extension Credential { } } } + + /// Introspect the token to check it for validity, and read the additional information associated with it. + /// - Parameters: + /// - type: Type of token to introspect. + public func introspect(_ type: Token.Kind) async throws -> TokenInfo { + try await withCheckedThrowingContinuation { continuation in + oauth2.introspect(token: token, type: type) { result in + continuation.resume(with: result) + } + } + } } #endif diff --git a/Tests/AuthFoundationTests/ClaimTests.swift b/Tests/AuthFoundationTests/ClaimTests.swift new file mode 100644 index 000000000..834f98468 --- /dev/null +++ b/Tests/AuthFoundationTests/ClaimTests.swift @@ -0,0 +1,79 @@ +// +// 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 XCTest +@testable import AuthFoundation +import TestCommon + +struct TestClaims: HasClaims { + enum TestClaim: String, IsClaim { + case firstName, lastName, modifiedDate, webpage, roles + } + + enum Role: String, ClaimConvertable, IsClaim { + case admin, superuser, user, guest + } + + typealias ClaimType = TestClaim + let payload: [String: Any] +} + +extension Date { + static func nowTruncated() throws -> Date { + var dateComponents = Calendar.current.dateComponents(in: try XCTUnwrap(TimeZone(identifier: "UTC")), from: Date()) + dateComponents.second = 0 + dateComponents.nanosecond = 0 + return try XCTUnwrap(dateComponents.date) + } +} + +final class ClaimTests: XCTestCase { + func testClaimConvertible() throws { + let date = try Date.nowTruncated() + let dateString = ISO8601DateFormatter().string(from: date) + let container = TestClaims(payload: [ + "firstName": "Jane", + "lastName": "Doe", + "modifiedDate": dateString, + "webpage": "https://example.com/jane.doe/", + "roles": ["admin", "user"] + ]) + let webpage = try XCTUnwrap(URL(string: "https://example.com/jane.doe/")) + + XCTAssertEqual(container["firstName"], "Jane") + XCTAssertEqual(container[.firstName], "Jane") + + XCTAssertEqual(container["lastName"], "Doe") + XCTAssertEqual(container[.lastName], "Doe") + + XCTAssertEqual(container["modifiedDate"], dateString) + XCTAssertEqual(container[.modifiedDate], dateString) + XCTAssertEqual(container.value(Date.self, for: "modifiedDate"), date) + + 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(container["webpage"], "https://example.com/jane.doe/") + XCTAssertEqual(container[.webpage], "https://example.com/jane.doe/") + XCTAssertEqual(container.value(URL.self, for: "webpage"), webpage) + + var url: URL? + url = container["webpage"] + XCTAssertEqual(url, webpage) + + url = container[.webpage] + XCTAssertEqual(url, webpage) + } +} diff --git a/Tests/AuthFoundationTests/JWTTests.swift b/Tests/AuthFoundationTests/JWTTests.swift index e9d896f8f..976e6e88b 100644 --- a/Tests/AuthFoundationTests/JWTTests.swift +++ b/Tests/AuthFoundationTests/JWTTests.swift @@ -81,6 +81,6 @@ final class JWTTests: XCTestCase { func testAuthMethods() throws { let token = try JWT(idToken) - XCTAssertEqual(token.authenticationMethods, ["pwd"]) + XCTAssertEqual(token.authenticationMethods, [.passwordBased]) } } diff --git a/Tests/AuthFoundationTests/OAuth2ClientTests.swift b/Tests/AuthFoundationTests/OAuth2ClientTests.swift index e2f47f86b..2b23dba64 100644 --- a/Tests/AuthFoundationTests/OAuth2ClientTests.swift +++ b/Tests/AuthFoundationTests/OAuth2ClientTests.swift @@ -495,11 +495,11 @@ final class OAuth2ClientTests: XCTestCase { } func testRevokeRequestClientAuthentication() throws { - let request = Token.RevokeRequest(openIdConfiguration: openIdConfiguration, - clientAuthentication: .clientSecret("supersecret"), - token: "the-token", - hint: .deviceSecret, - configuration: [:]) + let request = try Token.RevokeRequest(openIdConfiguration: openIdConfiguration, + clientAuthentication: .clientSecret("supersecret"), + token: "the-token", + hint: .deviceSecret, + configuration: [:]) let parameters = try XCTUnwrap(request.bodyParameters as? [String: String]) XCTAssertEqual(parameters["token"], "the-token") XCTAssertEqual(parameters["token_type_hint"], "device_secret") diff --git a/Tests/AuthFoundationTests/OpenIDConfigurationTests.swift b/Tests/AuthFoundationTests/OpenIDConfigurationTests.swift index 1230e213b..0f77a8f46 100644 --- a/Tests/AuthFoundationTests/OpenIDConfigurationTests.swift +++ b/Tests/AuthFoundationTests/OpenIDConfigurationTests.swift @@ -140,7 +140,7 @@ final class OpenIDConfigurationTests: XCTestCase { XCTAssertEqual(config.introspectionEndpoint?.absoluteString, "https://example.okta.com/oauth2/v1/introspect") XCTAssertEqual(config.jwksUri.absoluteString, "https://example.okta.com/oauth2/v1/keys") XCTAssertEqual(config.registrationEndpoint?.absoluteString, "https://example.okta.com/oauth2/v1/clients") - XCTAssertEqual(config.revocationEndpoint.absoluteString, "https://example.okta.com/oauth2/v1/revoke") + XCTAssertEqual(config.revocationEndpoint?.absoluteString, "https://example.okta.com/oauth2/v1/revoke") XCTAssertEqual(config.tokenEndpoint.absoluteString, "https://example.okta.com/oauth2/v1/token") XCTAssertEqual(config.userinfoEndpoint?.absoluteString, "https://example.okta.com/oauth2/v1/userinfo") @@ -198,6 +198,36 @@ final class OpenIDConfigurationTests: XCTestCase { XCTAssertNil(config.introspectionEndpoint) XCTAssertNil(config.registrationEndpoint) XCTAssertNil(config.userinfoEndpoint) - XCTAssertTrue(config.claimsSupported.contains(.custom("is_private_email"))) + + let claimsSupported = try XCTUnwrap(config.claimsSupported) + XCTAssertTrue(claimsSupported.contains(.custom("is_private_email"))) + XCTAssertEqual(config.claimsSupported, [ + .audience, + .email, + .emailVerified, + .expirationTime, + .issuedAt, + .custom("is_private_email"), + .issuer, + .nonce, + .custom("nonce_supported"), + .custom("real_user_status"), + .subject, + .custom("transfer_sub"), + ]) + XCTAssertEqual(config.claimsSupported, [ + .audience, + .email, + .emailVerified, + .expirationTime, + .issuedAt, + .isPrivateEmail, + .issuer, + .nonce, + .nonceSupported, + .realUserStatus, + .subject, + .transferSubject, + ]) } }