Restore the .oidcCallback route for external authentication. (#5391)

* Restore the .oidcCallback route (partially reverts #3461) for external authentication.

* Make sure OIDC also works for non-http URLs.

* Remove oidcAuthentication from the state machine.

There isn't a reliable way to detect failure/cancellation when e.g. the user returns from an external app without interacting with the MAS page.
This commit is contained in:
Doug
2026-05-05 12:47:07 +01:00
committed by GitHub
parent aaf81e00f3
commit fef942f529
17 changed files with 181 additions and 52 deletions
@@ -9,31 +9,57 @@
import AuthenticationServices
/// Presents a web authentication session for an OIDC request.
///
/// In certain instances the URL may require opening an external app instead of using a WAS. Because of this
/// it is recommended to not encode the OIDC authentication within any state machines, as there is no guarantee
/// that any cancellations/failures will be communicated upwards.
@MainActor
class OIDCAuthenticationPresenter: NSObject {
private let authenticationService: AuthenticationServiceProtocol
private let oidcRedirectURL: URL
private let presentationAnchor: UIWindow
private let appMediator: AppMediatorProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private var activeSession: ASWebAuthenticationSession?
/// The data required to complete a request.
struct Request {
let session: ASWebAuthenticationSession
let continuation: CheckedContinuation<Response, Never>
}
struct Response {
let url: URL?
let isExternal: Bool
let error: Error?
}
private var activeRequest: Request?
init(authenticationService: AuthenticationServiceProtocol,
oidcRedirectURL: URL,
presentationAnchor: UIWindow,
appMediator: AppMediatorProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.authenticationService = authenticationService
self.oidcRedirectURL = oidcRedirectURL
self.presentationAnchor = presentationAnchor
self.appMediator = appMediator
self.userIndicatorController = userIndicatorController
super.init()
}
/// Presents a web authentication session for the supplied data.
///
/// **Note:** The failure case cannot be relied upon as a signal that the authentication has ended.
/// In particular if the authentication URL requires opening an external app, then the user may return
/// to the app without completing (or cancelling) the authentication.
func authenticate(using oidcData: OIDCAuthorizationDataProxy) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
let (url, error) = await withCheckedContinuation { continuation in
let session = ASWebAuthenticationSession(url: oidcData.url, callback: .oidcRedirectURL(oidcRedirectURL)) { url, error in
continuation.resume(returning: (url, error))
let response = await withCheckedContinuation { continuation in
let authenticationURL = oidcData.url
let session = ASWebAuthenticationSession(url: authenticationURL, callback: .oidcRedirectURL(oidcRedirectURL)) { url, error in
MXLog.info("Handling callback from the session.")
continuation.resume(returning: Response(url: url, isExternal: false, error: error))
}
session.prefersEphemeralWebBrowserSession = false
@@ -42,21 +68,31 @@ class OIDCAuthenticationPresenter: NSObject {
"X-Element-User-Agent": UserAgentBuilder.makeASCIIUserAgent()
]
activeSession = session
session.start()
activeRequest = Request(session: session, continuation: continuation)
if authenticationURL.scheme == "https" || authenticationURL.scheme == "http" {
session.start()
} else {
appMediator.open(authenticationURL)
}
}
activeSession = nil
if response.isExternal {
// Manually dismiss the web authentication session if the login was completed outside of the app.
// Note: This doesn't trigger a callback so no need to worry about the continuation being called twice.
activeRequest?.session.cancel()
}
activeRequest = nil
guard let url else {
// Check for user cancellation to avoid showing an alert in that instance.
if error?.isOIDCUserCancellation == true {
guard let url = response.url else {
// Check for user cancellation (on the WAS sheet) to avoid showing an alert in that instance.
if response.error?.isOIDCUserCancellation == true {
// No need to show an error here, just abort and return a failure.
await authenticationService.abortOIDCLogin(data: oidcData)
return .failure(.oidcError(.userCancellation))
}
let errorDescription = error.map(String.init(describing:)) ?? "Unknown error"
let errorDescription = response.error.map(String.init(describing:)) ?? "Unknown error"
MXLog.error("Missing callback URL from the web authentication session: \(errorDescription)")
showFailureIndicator()
@@ -71,7 +107,7 @@ class OIDCAuthenticationPresenter: NSObject {
switch await authenticationService.loginWithOIDCCallback(url) {
case .success(let userSession):
return .success(userSession)
case .failure(.oidcError(.userCancellation)):
case .failure(.oidcError(.userCancellation)): // Check for user cancellation (on the MAS web page)
// No need to show an error here, just return the failure.
return .failure(.oidcError(.userCancellation))
case .failure(let error):
@@ -81,8 +117,20 @@ class OIDCAuthenticationPresenter: NSObject {
}
}
/// This method will be used when the web authentication session redirects the user to an external
/// authentication app. During normal use the redirect is handled automatically by the session's closure.
func handleUniversalLinkCallback(_ url: URL) {
guard let activeRequest else {
MXLog.error("Failed to handle universal link callback. Missing request.")
return
}
MXLog.info("Handling callback from a universal link.")
activeRequest.continuation.resume(returning: Response(url: url, isExternal: true, error: nil))
}
func cancel() {
activeSession?.cancel()
activeRequest?.session.cancel()
activeRequest = nil // Programatically cancelling the session doesn't trigger a callback.
}
private var loadingIndicatorID: String {
@@ -13,6 +13,7 @@ struct SoftLogoutScreenCoordinatorParameters {
let authenticationService: AuthenticationServiceProtocol
let credentials: SoftLogoutScreenCredentials
let keyBackupNeeded: Bool
let appMediator: AppMediatorProtocol
let appSettings: AppSettings
let userIndicatorController: UserIndicatorControllerProtocol
}
@@ -90,6 +91,15 @@ final class SoftLogoutScreenCoordinator: CoordinatorProtocol {
AnyView(SoftLogoutScreen(context: viewModel.context))
}
func handleOIDCRedirectURL(_ url: URL) {
guard let oidcPresenter else {
MXLog.error("Failed to find an OIDC request in progress.")
return
}
oidcPresenter.handleUniversalLinkCallback(url)
}
// MARK: - Private
private static let loadingIndicatorIdentifier = "\(SoftLogoutScreenCoordinator.self)-Loading"
@@ -149,6 +159,7 @@ final class SoftLogoutScreenCoordinator: CoordinatorProtocol {
let presenter = OIDCAuthenticationPresenter(authenticationService: parameters.authenticationService,
oidcRedirectURL: parameters.appSettings.oidcRedirectURL,
presentationAnchor: presentationAnchor,
appMediator: parameters.appMediator,
userIndicatorController: parameters.userIndicatorController)
self.oidcPresenter = presenter
switch await presenter.authenticate(using: oidcData) {
@@ -17,17 +17,24 @@ import AuthenticationServices
@MainActor
class OIDCAccountSettingsPresenter: NSObject {
private let accountURL: URL
private let presentationAnchor: UIWindow
private let oidcRedirectURL: URL
private let presentationAnchor: UIWindow
private let appMediator: AppMediatorProtocol
typealias Continuation = AsyncStream<Result<Void, OIDCError>>.Continuation
private let continuation: Continuation?
init(accountURL: URL, presentationAnchor: UIWindow, appSettings: AppSettings, continuation: Continuation? = nil) {
init(accountURL: URL,
presentationAnchor: UIWindow,
appMediator: AppMediatorProtocol,
appSettings: AppSettings,
continuation: Continuation? = nil) {
self.accountURL = accountURL
self.presentationAnchor = presentationAnchor
oidcRedirectURL = appSettings.oidcRedirectURL
self.presentationAnchor = presentationAnchor
self.appMediator = appMediator
self.continuation = continuation
super.init()
}
@@ -53,7 +60,11 @@ class OIDCAccountSettingsPresenter: NSObject {
"X-Element-User-Agent": UserAgentBuilder.makeASCIIUserAgent()
]
session.start()
if accountURL.scheme == "https" || accountURL.scheme == "http" {
session.start()
} else {
appMediator.open(accountURL)
}
}
}