mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
#42: Proof of concept login via OIDC.
Uses AppAuth library in the app, ignoring token refresh
This commit is contained in:
@@ -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 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user