mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
#42: Proof of concept login via OIDC.
Uses AppAuth library in the app, ignoring token refresh
This commit is contained in:
@@ -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