Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extend Token with HasClaims for custom claims #204

Merged
merged 4 commits into from
Aug 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious, so this hides the extension from generated docs or from Xcode as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the generated docs. Many of these just clutter up the docs. For reference, here's the list of underscored attributes.

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 {}
Comment on lines -20 to -21

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does JWTClaim take care of array and dictionary?

Copy link
Contributor Author

@mikenachbaur-okta mikenachbaur-okta Aug 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's unnecessary since the accessors for working with claims (e.g. value(...) or subscript methods) address arrays or dictionaries already.

The goal for ClaimConvertable is to map some JSON primitive supplied from the server into a more developer-friendly value (e.g. from a string to URL, or to an enum, etc.). As a result, arrays and dictionaries themselves aren't all that interesting to be converted.

In fact, the only reason Array and Dictionary were made to be convertable in the first place was because the value functions were being used inconsistently, which prevented their values from being properly converted in the first place.


@_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
Loading