-
Notifications
You must be signed in to change notification settings - Fork 20
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
Refactor Authentication Flows for consistency and customization #214
Conversation
This introduces a generic authentication flow configuration protocol that various authenetication flows can support. Concrete implementations for other OAuth2 flows to follow.
…ation views for OOB factors
- Introduces the concept of an Authentication Context, which is used to supply flow-specific capabilities to adapt the flows to specific use-cases. - Normalize the coalescence of values supplied to OAuth2Client configuration, auth flow context, and user-supplied information. - Introduce support for ACR Values - Pave the way for refactoring these objects and types to support actors and @sendable
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changes look okay to me, just some nits and quibbles.
/// Indicates if this flow is currently authenticating. | ||
var isAuthenticating: Bool { get } | ||
|
||
/// Optional request parameters to be added to requests made from this flow. | ||
var additionalParameters: [String: APIRequestArgument]? { get } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see the need to have this as optional unless we have some special behaviour in mind for the empty dictionary case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My feeling is that since the property is optional, it makes sense for it to actually be an Optional
, particularly since it feels odd to say something is optional on one had while requiring a result on the other.
Additionally, most developers using this won't see the API documentation (or, if they're attentive, they might see the inline docs during code completion in Xcode). So in general, if something is optional, the API contract should establish that in a self-documenting fashion.
|
||
/// The list of OAuth2 scopes requested for this client. | ||
public let scopes: String | ||
public var scope: String |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess the spec refers to this as scope
even though it's almost universally treated as a space separated list of scopes? Given that we're already changing the public api, is it worth providing a scopes: [String]
(or scopes: Set<String>
) variable that automatically updates scope
with a space separated list of values?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I'm trying to keep the naming as consistent with the specs as possible. And I agree, the space-separated list encoded as a string, as opposed to an array, does feel "non-swifty". And since acrValues
is also a space-separated list in the spec and I'm representing it as an array, it makes sense to align both properties for consistency.
I'm not sure about making it a Set though. While the spec doesn't mention it, I don't want to remove the ability for a developer to specify duplicate values if they choose to do so. Plus, since one of the goals of this SDK is to have architectural and functional parity across different languages, I don't want to make it difficult for Javascript to adopt later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with @bmitchelmore that if we're going to change it, wouldn't [String]
would be better? Then we can handle the details of string concatenation for them
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@stevelind-okta I'm discussing this with the other SDK engineers. A change like this should be applied consistently to the other SDKs since this is an API spec change that's applicable to the other languages. I agree that we should be consistent in how whitespace-separated collections are handled in the SDK, but the question remains whether or not we should process the responses/request arguments from the Authorization Server for developer convenience, or if we should stick to the spec.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Update: I've discussed this with the other SDK engineers, and we've decided to adopt [String]
across the board for these sorts of properties. I've updated the design guidelines with this decision, and will update this PR to make this consistent in similar values.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@stevelind-okta & @bmitchelmore I'll be adding these updates in another PR to keep the scope of this one down. But suffice to say, scope
will become an array, just the same as acrValues
.
if let container = try? decoder.container(keyedBy: CodingKeysV1.self), | ||
container.allKeys.contains(.baseURL) | ||
{ | ||
self.init(issuerURL: try container.decode(URL.self, forKey: .baseURL), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My personal preference for a situation like this would be to have two separate inits that each take a Decoder and each one attempts decoding using the different coding keys set, then this init would just delegate to those inits rather than baking it all into this one init
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like the one method which steps through the different decoding strategies because not all version migration features are as straightforward as this one. For example, in Token+Initialization.swift, adapting between v1 and v2 isn't as straightforward, particularly since it uses JSONEncoder.userInfo
to pull in context from outside the encoded data. So having both init(from:)
initializers follow the same patterns is useful for consistency.
if let scope = scope { | ||
result["scope"] = scope | ||
} else { | ||
result.removeValue(forKey: "scope") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was it possible for clientConfiguration.authentication to contain a scope value previously? Or for clientConfiguration.parameters(for: category) to contain a scope value now? Is there a reason to explicitly disallow the introduction of a scope via that mechanism? Is it documented?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is more about the spec's definition around refreshing access tokens than client configuration.
A scope
value is optional when making a refresh request, because when this value is omitted it means the Authorization Server should return a new access token with the same set of scopes that was originally returned. However, if a client explicitly includes a set of scopes when refreshing an access token, this is a way for the client to request a "downscope", constraining the resulting access token to a subset of the original scopes. This is a useful feature which is something that'll be added in a subsequent PR.
For completeness sake, the value returned from the if let scope = scope
line is the explicit scope requested when performing a refresh, and not the scope implicitly referenced in the client configuration. So if a developer isn't explicitly asking for a particular set of scopes during the refresh request, we're following the spec and are omitting the value from the request.
parameters.merge(client.configuration.parameters(for: .authorization)) | ||
parameters.merge(context.parameters(for: .authorization)) | ||
|
||
components.percentEncodedQuery = parameters |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are we manually percent encoding here? Can't URLComponents do the url encoding for you if you give it a queryItems array?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's what this code used to do, except the default encoding strategy improperly uses %20
instead of +
, which causes problems with handling of scope values on some authorization servers. #99
Sources/OktaOAuth2/Internal/Requests/DeviceAuthorizeFlow+Requests.swift
Outdated
Show resolved
Hide resolved
inProgress = true | ||
|
||
client.openIdConfiguration { result in | ||
defer { self.reset() } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like you call reset at the end of every code path, was defer not working as expected?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If an error is thrown, the reset()
function shouldn't be called, so I moved it out of the defer
block and opted to call reset explicitly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, but you're still calling reset inside your catch block?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm good catch 👍
The goal of this behavior is to differentiate between a server-generated error, or a local SDK failure. In the event the developer did something wrong, or if there's a spurious error, the context shouldn't be automatically reset, enabling them to debug & diagnose the issue.
I'll look into this one ...
let regex = try! NSRegularExpression(pattern: "([a-z])([A-Z])") | ||
let range = NSRange(location: 0, length: self.count) | ||
let snakeCased = regex.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: "$1_$2") | ||
return snakeCased.lowercased() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will map isHTTPResponse
to is_httpresponse
right? Acceptable?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct. There are more complete solutions that would map to is_http_response
for example, but for the purposes of this SDK (where the aim is to map keys used in the OAuth2 specs) this seems fine, and easier to read. However @bmitchelmore, if you feel like it would make sense, I could change the implementation to iterate over the characters similar to this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think if you create a public extension, you introduce the possibility of someone else using it not aware of that specific limitation and suffering the consequences. So it might be worth it to either document the limitation or use an alternate implementation, but I leave that up to you.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bmitchelmore I updated the implementation to be more "feature complete". It's still probably faster than using a regular expression, but it does look a bit more verbose. 🤷🏼♀️
@@ -148,7 +148,22 @@ public protocol APIParsingContext { | |||
} | |||
|
|||
extension APIParsingContext { | |||
@_documentation(visibility: private) | |||
public var codingUserInfo: [CodingUserInfoKey: Any]? { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
UserInfo
may be confused with OIDC userInfo
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
UserInfo is actually a common property name used throughout Apple's frameworks. In this case, this corresponds to the JSONEncoder's userInfo property, which is used to pass additional information needed to properly encode/decode data.
"client_id": client.configuration.clientId, | ||
"scope": client.configuration.scopes | ||
]) | ||
scope: nil, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is scope: nil
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because it's inheriting the scopes from the initial token exchange. But this argument is being exposed to the refresh
function for future expansion, when I introduce support for downscoping refresh tokens. I just didn't want to overly conflate this PR with additional user-facing features.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is awesome!
@@ -39,17 +33,31 @@ struct OOBResponse: Codable, HasTokenParameters { | |||
func tokenParameters(currentStatus: DirectAuthenticationFlow.Status?) -> [String: APIRequestArgument] { | |||
["oob_code": oobCode] | |||
} | |||
|
|||
static func == (lhs: OOBResponse, rhs: OOBResponse) -> Bool { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is bindingCode not important for comparison here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great catch! 👍 Thanks, I'll make sure to add this in my next PR.
/// - context: Optional context to provide when customizing the state parameter. | ||
/// - additionalParameters: Optional additional query string parameters you would like to supply to the authorization server. | ||
/// - options: Options to customize this authentication flow. | ||
/// - Returns: The URL a user should be presented with within a browser, to continue authorization. | ||
public func start(with context: Context? = nil, additionalParameters: [String: String]? = nil) async throws -> URL { | ||
public func start(with context: Context = .init()) async throws -> URL { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see options in the function signature?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again, good catch! Thanks! 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This PR looks good to me from SDK and Oauth side. Please don't merge until other Swift devs give their approval
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Big PR with lots of changes. Hard to be utterly confident, but it looks ok to me.
This introduces a generic authentication flow configuration protocol that various authentication flows can support.
It refactors all authentication flows for consistency and customization by:
@Sendable