Skip to content

Commit

Permalink
Extend Token with HasClaims for custom claims (#204)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
mikenachbaur-okta authored Aug 12, 2024
1 parent 03ab528 commit c41191a
Show file tree
Hide file tree
Showing 50 changed files with 1,244 additions and 511 deletions.
42 changes: 23 additions & 19 deletions Sources/AuthFoundation/AuthFoundation.docc/AuthFoundation.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,16 @@ You can use AuthFoundation when you want to:

### JWT and Token Verification

- <doc:WorkingWithClaims>
- ``JWT``
- ``JWK``
- ``JWKS``
- ``JWTClaim``
- ``HasClaims``
- ``Claim``
- ``JSONClaimContainer``
- ``JSON``
- ``AnyJSON``
- ``ClaimConvertable``
- ``IsClaim``
- ``Expires``
Expand All @@ -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``
25 changes: 25 additions & 0 deletions Sources/AuthFoundation/AuthFoundation.docc/WorkingWithClaims.md
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down Expand Up @@ -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``.
Expand Down
9 changes: 7 additions & 2 deletions Sources/AuthFoundation/JWT/Enums/JWTClaim.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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)
}
}
165 changes: 165 additions & 0 deletions Sources/AuthFoundation/JWT/Extensions/Claim+ValueExtensions.swift
Original file line number Diff line number Diff line change
@@ -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<T: ClaimConvertable>(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<T: ClaimConvertable>(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<T: ClaimConvertable>(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<T: ClaimConvertable>(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<T: ClaimConvertable>(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<T: ClaimConvertable>(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<T: ClaimConvertable>(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<T: ClaimConvertable>(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<T: ClaimConvertable>(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<T: ClaimConvertable>(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<T: ClaimConvertable>(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<T: ClaimConvertable>(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<T: ClaimConvertable>(_ 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<T: ClaimConvertable>(_ 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<T: ClaimConvertable>(_ 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<T: ClaimConvertable>(_ 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<T: ClaimConvertable>(_ 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<T: ClaimConvertable>(_ key: String) -> [String: T]? {
value(for: key)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>: ClaimConvertable {}
extension Dictionary<String, String>: 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))
}
Expand All @@ -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
}
}
Loading

0 comments on commit c41191a

Please sign in to comment.