#42: Proof of concept login via OIDC.

Uses AppAuth library in the app, ignoring token refresh
This commit is contained in:
Doug
2022-07-28 09:28:42 +01:00
committed by GitHub
parent f8c30a7341
commit c20bc6bd1c
20 changed files with 323 additions and 57 deletions
@@ -110,8 +110,6 @@ class AuthenticationCoordinator: Coordinator, Presentable {
switch action {
case .signedIn(let userSession):
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
case .continueWithOIDC:
break
}
}
@@ -14,6 +14,7 @@
// limitations under the License.
//
import AppAuth
import MatrixRustSDK
import SwiftUI
@@ -27,8 +28,6 @@ struct LoginCoordinatorParameters {
enum LoginCoordinatorAction {
/// Login was successful.
case signedIn(UserSessionProtocol)
/// Continue using OIDC.
case continueWithOIDC
}
final class LoginCoordinator: Coordinator, Presentable {
@@ -39,6 +38,8 @@ final class LoginCoordinator: Coordinator, Presentable {
private let parameters: LoginCoordinatorParameters
private let loginHostingController: UIViewController
private var loginViewModel: LoginViewModelProtocol
/// Passed to the OIDC service to provide a view controller from which to present the authentication session.
private let oidcUserAgent: OIDExternalUserAgentIOS?
private var currentTask: Task<Void, Error>? {
willSet {
@@ -69,6 +70,7 @@ final class LoginCoordinator: Coordinator, Presentable {
loginHostingController = UIHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: loginHostingController)
oidcUserAgent = OIDExternalUserAgentIOS(presenting: loginHostingController)
}
// MARK: - Public
@@ -90,7 +92,7 @@ final class LoginCoordinator: Coordinator, Presentable {
case .login(let username, let password):
self.login(username: username, password: password)
case .continueWithOIDC:
self.callback?(.continueWithOIDC)
self.loginWithOIDC()
}
}
}
@@ -138,6 +140,26 @@ final class LoginCoordinator: Coordinator, Presentable {
}
}
private func loginWithOIDC() {
guard let oidcUserAgent = oidcUserAgent else {
handleError(AuthenticationServiceError.oidcError(.notSupported))
return
}
startLoading(isInteractionBlocking: true)
Task {
switch await authenticationService.loginWithOIDC(userAgent: oidcUserAgent) {
case .success(let userSession):
callback?(.signedIn(userSession))
stopLoading()
case .failure(let error):
stopLoading()
handleError(error)
}
}
}
/// Requests the authentication coordinator to log in using the specified credentials.
private func login(username: String, password: String) {
startLoading(isInteractionBlocking: true)
@@ -53,7 +53,8 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
init(parameters: HomeScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = HomeScreenViewModel(attributedStringBuilder: parameters.attributedStringBuilder)
viewModel = HomeScreenViewModel(initialDisplayName: parameters.userSession.userID,
attributedStringBuilder: parameters.attributedStringBuilder)
let view = HomeScreen(context: viewModel.context)
hostingController = UIHostingController(rootView: view)
@@ -31,7 +31,7 @@ enum HomeScreenViewAction {
}
struct HomeScreenViewState: BindableState {
var userDisplayName: String?
var userDisplayName: String
var userAvatar: UIImage?
var showSessionVerificationBanner = false
@@ -39,10 +39,11 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
// MARK: - Setup
init(attributedStringBuilder: AttributedStringBuilderProtocol) {
init(initialDisplayName: String, attributedStringBuilder: AttributedStringBuilderProtocol) {
self.attributedStringBuilder = attributedStringBuilder
super.init(initialViewState: HomeScreenViewState(isLoadingRooms: true))
super.init(initialViewState: HomeScreenViewState(userDisplayName: initialDisplayName,
isLoadingRooms: true))
}
// MARK: - Public
@@ -84,7 +84,7 @@ struct HomeScreen: View {
}
}
}
@ViewBuilder
private var userAvatarImage: some View {
if let avatar = context.viewState.userAvatar {
@@ -94,21 +94,14 @@ struct HomeScreen: View {
.frame(width: 32, height: 32, alignment: .center)
.clipShape(Circle())
.accessibilityIdentifier("userAvatarImage")
} else {
EmptyView()
}
}
@ViewBuilder
private var userDisplayNameView: some View {
if let displayName = context.viewState.userDisplayName {
Text(displayName)
.font(.headline)
.fontWeight(.bold)
.foregroundColor(.primary)
} else {
EmptyView()
}
Text(context.viewState.userDisplayName)
.font(.headline)
.fontWeight(.bold)
.foregroundColor(.primary)
}
}
@@ -173,11 +166,14 @@ struct RoomCell: View {
struct HomeScreen_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
.tint(.element.accent)
body.preferredColorScheme(.dark)
.tint(.element.accent)
}
static var body: some View {
let viewModel = HomeScreenViewModel(attributedStringBuilder: AttributedStringBuilder())
let viewModel = HomeScreenViewModel(initialDisplayName: "@username:server.com",
attributedStringBuilder: AttributedStringBuilder())
let eventBrief = EventBrief(eventId: "id",
senderId: "senderId",
@@ -6,6 +6,7 @@
// Copyright © 2022 Element. All rights reserved.
//
import AppAuth
import Foundation
import MatrixRustSDK
@@ -59,6 +60,42 @@ class AuthenticationServiceProxy: AuthenticationServiceProxyProtocol {
}
}
func loginWithOIDC(userAgent: OIDExternalUserAgentIOS) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
guard case let .oidc(issuerURL) = homeserver.loginMode else {
return .failure(.oidcError(.notSupported))
}
let token: String
let deviceID = generateDeviceID()
do {
let oidcService = OIDCService(issuerURL: issuerURL)
let configuration = try await oidcService.metadata()
let registationResponse = try await oidcService.registerClient(metadata: configuration)
let authResponse = try await oidcService.presentWebAuthentication(metadata: configuration,
clientID: registationResponse.clientID,
scope: "urn:matrix:device:\(deviceID)",
userAgent: userAgent)
let tokenResponse = try await oidcService.redeemCodeForTokens(authResponse: authResponse)
guard let accessToken = tokenResponse.accessToken else { return .failure(.oidcError(.unknown)) }
token = accessToken
} catch let error as OIDCError {
MXLog.error("Login with OIDC failed: \(error)")
return .failure(.oidcError(error))
} catch {
MXLog.error("Login with OIDC failed: \(error)")
return .failure(.failedLoggingIn)
}
do {
let client = try authenticationService.restoreWithAccessToken(token: token, deviceId: deviceID)
return await userSession(for: client)
} catch {
MXLog.debug(error)
return .failure(.failedLoggingIn)
}
}
func login(username: String, password: String) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
Benchmark.startTrackingForIdentifier("Login", message: "Started new login")
@@ -97,4 +134,13 @@ class AuthenticationServiceProxy: AuthenticationServiceProxyProtocol {
return .failure(.failedLoggingIn)
}
}
private func generateDeviceID() -> String {
var deviceID = ""
for _ in 0..<10 {
guard let scalar = UnicodeScalar(Int.random(in: 65...90)) else { fatalError() }
deviceID.append(Character(scalar))
}
return deviceID
}
}
@@ -6,9 +6,12 @@
// Copyright © 2022 Element. All rights reserved.
//
import AppAuth
import Foundation
enum AuthenticationServiceError: Error {
/// An error occurred during OIDC authentication.
case oidcError(OIDCError)
case invalidServer
case invalidCredentials
case invalidHomeserverAddress
@@ -22,6 +25,8 @@ protocol AuthenticationServiceProxyProtocol {
/// Sets up the service for login on the specified homeserver address.
func configure(for homeserverAddress: String) async -> Result<Void, AuthenticationServiceError>
/// Performs login using OIDC for the current homeserver.
func loginWithOIDC(userAgent: OIDExternalUserAgentIOS) async -> Result<UserSessionProtocol, AuthenticationServiceError>
/// Performs a password login using the current homeserver.
func login(username: String, password: String) async -> Result<UserSessionProtocol, AuthenticationServiceError>
}
@@ -6,11 +6,13 @@
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import AppAuth
class MockAuthenticationServiceProxy: AuthenticationServiceProxyProtocol {
let validCredentials = (username: "alice", password: "12345678")
private(set) var homeserver: LoginHomeserver = .mockMatrixDotOrg
var oidcUserAgent: OIDExternalUserAgentIOS?
func configure(for homeserverAddress: String) async -> Result<Void, AuthenticationServiceError> {
// Map the address to the mock homeservers
@@ -32,6 +34,10 @@ class MockAuthenticationServiceProxy: AuthenticationServiceProxyProtocol {
}
}
func loginWithOIDC(userAgent: OIDExternalUserAgentIOS) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
.failure(.oidcError(.notSupported))
}
func login(username: String, password: String) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
// Login only succeeds if the username and password match the valid credentials property
guard username == validCredentials.username, password == validCredentials.password else {
@@ -0,0 +1,140 @@
//
// OIDCAuthenticationService.swift
// ElementX
//
// Created by Doug on 07/07/2022.
// Copyright © 2022 Element. All rights reserved.
//
import AppAuth
/// Errors thrown by the OIDC service.
enum OIDCError: Error {
case notSupported
case metadataMissingRegistrationEndpoint
case userCancellation
case missingTokenExchangeRequest
case unknown
}
/// A proof of concept implementation of a service that assists with authentication via OIDC.
/// It will be replaced by an implementation in the Rust SDK tracked in the following issue:
/// https://github.com/matrix-org/matrix-rust-sdk/issues/859
class OIDCService {
private let issuerURL: URL
private var authState: OIDAuthState
private var metadata: OIDServiceConfiguration?
/// Redirect URI for the request. Must match the `client_uri` in reverse DNS format.
private let redirectURI = URL(string: "io.element:/callback")!
// swiftlint:disable:previous force_unwrapping
/// Maintains a strong ref to the authorization session that's in progress.
private var session: OIDExternalUserAgentSession?
init(issuerURL: URL) {
self.issuerURL = issuerURL
authState = OIDAuthState(authorizationResponse: nil, tokenResponse: nil, registrationResponse: nil)
}
/// Get OpenID Connect endpoints and ensure that dynamic client registration is configured.
func metadata() async throws -> OIDServiceConfiguration {
let metadata = try await OIDAuthorizationService.discoverConfiguration(forIssuer: issuerURL)
guard metadata.registrationEndpoint != nil else {
throw OIDCError.metadataMissingRegistrationEndpoint
}
return metadata
}
/// Perform dynamic client registration and then store the response
func registerClient(metadata: OIDServiceConfiguration) async throws -> OIDRegistrationResponse {
let extraParams = [
"client_name": "ElementX iOS",
"client_uri": "https://element.io",
"tos_uri": "https://example.com/tos",
"policy_uri": "https://example.com/policy"
]
let nonTemplatizedRequest = OIDRegistrationRequest(
configuration: metadata,
redirectURIs: [redirectURI],
responseTypes: nil,
grantTypes: [OIDGrantTypeAuthorizationCode],
subjectType: nil,
tokenEndpointAuthMethod: "none",
additionalParameters: extraParams
)
let registrationResponse = try await OIDAuthorizationService.perform(nonTemplatizedRequest)
MXLog.info("Registration data retrieved successfully")
MXLog.debug("Created dynamic client: ID: \(registrationResponse.clientID)")
// This is a PoC, a complete implementation would persist the client ID and secret for reuse.
return registrationResponse
}
/// Trigger a redirect with standard parameters.
/// `acr_values` can be sent as an extra parameter, to control authentication methods.
func presentWebAuthentication(metadata: OIDServiceConfiguration,
clientID: String,
scope: String,
userAgent: OIDExternalUserAgent) async throws -> OIDAuthorizationResponse {
let scopesArray = scope.components(separatedBy: " ")
let request = OIDAuthorizationRequest(configuration: metadata,
clientId: clientID,
clientSecret: nil,
scopes: scopesArray,
redirectURL: redirectURI,
responseType: OIDResponseTypeCode,
additionalParameters: nil)
let result: OIDAuthorizationResponse = try await withCheckedThrowingContinuation { continuation in
self.session = OIDAuthorizationService.present(request, externalUserAgent: userAgent) { response, error in
guard let response = response else {
if let error = error {
MXLog.info("User cancelled the ASWebAuthenticationSession window")
continuation.resume(with: .failure(self.isUserCancellationError(error) ? OIDCError.userCancellation : error))
} else {
continuation.resume(with: .failure(OIDCError.unknown))
}
return
}
MXLog.info("Authorization response received successfully")
continuation.resume(with: .success(response))
}
}
return result
}
/// Handle the authorization response, including the user closing the Chrome Custom Tab
func redeemCodeForTokens(authResponse: OIDAuthorizationResponse) async throws -> OIDTokenResponse {
guard let request = authResponse.tokenExchangeRequest() else { throw OIDCError.missingTokenExchangeRequest }
return try await OIDAuthorizationService.perform(request, originalAuthorizationResponse: authResponse)
}
/// We can check for specific error codes to handle the user cancelling the ASWebAuthenticationSession window.
private func isUserCancellationError(_ error: Error) -> Bool {
let error = error as NSError
return error.domain == OIDGeneralErrorDomain && error.code == OIDErrorCode.userCanceledAuthorizationFlow.rawValue
}
}
extension OIDAuthorizationService {
/// An async version of `perform(_:originalAuthorizationResponse:callback:)`.
class func perform(_ request: OIDTokenRequest,
originalAuthorizationResponse authorizationResponse: OIDAuthorizationResponse?) async throws -> OIDTokenResponse {
try await withCheckedThrowingContinuation { continuation in
perform(request, originalAuthorizationResponse: authorizationResponse) { response, error in
guard let response = response else {
continuation.resume(with: .failure(error ?? OIDCError.unknown))
return
}
continuation.resume(with: .success(response))
}
}
}
}
@@ -12,6 +12,7 @@ struct MockUserSession: UserSessionProtocol {
let callbacks = PassthroughSubject<UserSessionCallback, Never>()
let sessionVerificationController: SessionVerificationControllerProxyProtocol? = nil
let userID = "@mock:usersession.com"
let clientProxy: ClientProxyProtocol
let mediaProvider: MediaProviderProtocol
}
@@ -13,6 +13,8 @@ class UserSession: UserSessionProtocol {
private var cancellables = Set<AnyCancellable>()
private var checkForSessionVerificationControllerCancellable: AnyCancellable?
var userID: String { clientProxy.userIdentifier }
let clientProxy: ClientProxyProtocol
let mediaProvider: MediaProviderProtocol
let callbacks = PassthroughSubject<UserSessionCallback, Never>()
@@ -15,6 +15,8 @@ enum UserSessionCallback {
}
protocol UserSessionProtocol {
var userID: String { get }
var clientProxy: ClientProxyProtocol { get }
var mediaProvider: MediaProviderProtocol { get }