#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
@@ -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 }