diff --git a/Sources/APIClient.swift b/Sources/APIClient.swift index 167e8e4a..e8309975 100644 --- a/Sources/APIClient.swift +++ b/Sources/APIClient.swift @@ -7,6 +7,13 @@ enum GrantType: String { case biometric = "urn:authgear:params:oauth:grant-type:biometric-request" case idToken = "urn:authgear:params:oauth:grant-type:id-token" case app2app = "urn:authgear:params:oauth:grant-type:app2app-request" + case settingsAction = "urn:authgear:params:oauth:grant-type:settings-action" +} + +enum ResponseType: String { + case code + case settingsAction = "urn:authgear:params:oauth:response-type:settings-action" + case none } struct APIResponse: Decodable { @@ -36,6 +43,7 @@ struct OIDCAuthenticationRequest { let maxAge: Int? let wechatRedirectURI: String? let page: AuthenticationPage? + let settingsAction: SettingsAction? func toQueryItems(clientID: String, verifier: CodeVerifier?) -> [URLQueryItem] { var queryItems = [ @@ -108,6 +116,10 @@ struct OIDCAuthenticationRequest { queryItems.append(URLQueryItem(name: "x_page", value: page.rawValue)) } + if let settingsAction = self.settingsAction { + queryItems.append(URLQueryItem(name: "x_settings_action", value: settingsAction.rawValue)) + } + if self.isSSOEnabled == false { // For backward compatibility // If the developer updates the SDK but not the server diff --git a/Sources/Authgear.swift b/Sources/Authgear.swift index b280ff05..ee8642ba 100644 --- a/Sources/Authgear.swift +++ b/Sources/Authgear.swift @@ -43,7 +43,8 @@ struct AuthenticateOptions { idTokenHint: nil, maxAge: nil, wechatRedirectURI: self.wechatRedirectURI, - page: self.page + page: self.page, + settingsAction: nil ) } } @@ -79,7 +80,8 @@ struct ReauthenticateOptions { idTokenHint: idTokenHint, maxAge: self.maxAge ?? 0, wechatRedirectURI: self.wechatRedirectURI, - page: nil + page: nil, + settingsAction: nil ) } } @@ -209,6 +211,10 @@ public enum SettingsPage: String { case identity = "/settings/identities" } +public enum SettingsAction: String { + case changePassword = "change_password" +} + public enum SessionStateChangeReason: String { case noToken = "NO_TOKEN" case foundToken = "FOUND_TOKEN" @@ -245,6 +251,7 @@ public class Authgear { private static let ExpireInPercentage = 0.9 static let CodeChallengeMethod = "S256" + private static let SDKRedirectURI = "nocallback://host/path" let name: String let clientId: String @@ -975,8 +982,12 @@ public class Authgear { func generateURL( redirectURI: String, + responseType: ResponseType, uiLocales: [String]? = nil, colorScheme: ColorScheme? = nil, + settingsAction: SettingsAction? = nil, + idTokenHint: String? = nil, + verifier: CodeVerifier? = nil, wechatRedirectURI: String? = nil, handler: URLCompletionHandler? ) { @@ -1002,7 +1013,7 @@ public class Authgear { let endpoint = try self.buildAuthorizationURL(request: OIDCAuthenticationRequest( redirectURI: redirectURI, - responseType: "none", + responseType: responseType.rawValue, scope: ["openid", "offline_access", "https://authgear.com/scopes/full-access"], isSSOEnabled: self.isSSOEnabled, state: nil, @@ -1011,11 +1022,12 @@ public class Authgear { loginHint: loginHint, uiLocales: uiLocales, colorScheme: colorScheme, - idTokenHint: nil, + idTokenHint: idTokenHint, maxAge: nil, wechatRedirectURI: wechatRedirectURI, - page: nil - ), verifier: nil) + page: nil, + settingsAction: settingsAction + ), verifier: verifier) handler?(.success(endpoint)) } catch { @@ -1050,7 +1062,7 @@ public class Authgear { } urlComponents.queryItems = queryItems let redirectURI = urlComponents.url! - self.generateURL(redirectURI: redirectURI.absoluteString) { generatedResult in + self.generateURL(redirectURI: redirectURI.absoluteString, responseType: .none) { generatedResult in switch generatedResult { case let .failure(err): handler?(.failure(err)) @@ -1095,18 +1107,15 @@ public class Authgear { self.uiImplementation.openAuthorizationURL( url: endpoint, - // Opening an arbitrary URL does not have a clear goal. - // So here we pass a placeholder redirect uri. - redirectURI: URL(string: "nocallback://host/path")!, - // prefersEphemeralWebBrowserSession is true so that - // the alert dialog is never prompted and + redirectURI: URL(string: Authgear.SDKRedirectURI)!, + // shareCookiesWithDeviceBrowser is false so that + // ASWebAuthenticationSession alert dialog is never prompted and // the app session token cookie is forgotten when the webview is closed. - shareCookiesWithDeviceBrowser: true + shareCookiesWithDeviceBrowser: false ) { result in self.unregisterCurrentWechatRedirectURI() switch result { case .success: - // This branch is unreachable. handler?(.success(())) case let .failure(error): if case AuthgearError.cancel = error { @@ -1134,6 +1143,149 @@ public class Authgear { ) } + private func openSettingsAction( + redirectURI: String, + action: SettingsAction, + uiLocales: [String]? = nil, + colorScheme: ColorScheme? = nil, + wechatRedirectURI: String? = nil, + handler: VoidCompletionHandler? = nil + ) { + let verifier = CodeVerifier() + let this = self + + self.refreshIDToken(handler: { result in + switch result { + case .success: + self.generateURL( + redirectURI: redirectURI, + responseType: .settingsAction, + uiLocales: uiLocales, + colorScheme: colorScheme, + settingsAction: action, + idTokenHint: self.idTokenHint, + verifier: verifier + ) { generatedResult in + switch generatedResult { + case let .failure(err): + handler?(.failure(err)) + case let .success(url): + // For opening setting page, sdk will not know when user end + // the setting page. + // So we cannot unregister the wechat uri in this case + // It is fine to not unresgister it, as everytime we open a + // new authorize section (authorize or setting page) + // registerCurrentWeChatRedirectURI will be called and overwrite + // previous registered wechatRedirectURI + self.registerCurrentWechatRedirectURI(uri: wechatRedirectURI) + + self.uiImplementation.openAuthorizationURL( + url: url, + redirectURI: URL(string: redirectURI)!, + // shareCookiesWithDeviceBrowser is false so that + // ASWebAuthenticationSession alert dialog is never prompted and + // the app session token cookie is forgotten when the webview is closed. + shareCookiesWithDeviceBrowser: false + ) { result in + self.unregisterCurrentWechatRedirectURI() + switch result { + case let .success(url): + this.finishSettingsAction(url: url, redirectURI: redirectURI, verifier: verifier) { result in + switch (result) { + case .success: + handler?(result) + case let .failure(error): + handler?(.failure(wrapError(error: error))) + } + } + case let .failure(error): + handler?(.failure(wrapError(error: error))) + } + } + } + } + case let .failure(error): + handler?(.failure(wrapError(error: error))) + } + }) + } + + func finishSettingsAction( + url: URL, + redirectURI: String, + verifier: CodeVerifier, + handler: VoidCompletionHandler + ) { + let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)! + let params = urlComponents.queryParams + + if let errorParams = params["error"] { + if errorParams == "cancel" { + return handler( + .failure(AuthgearError.cancel) + ) + } + return handler( + .failure(AuthgearError.oauthError( + OAuthError( + error: errorParams, + errorDescription: params["error_description"], + errorUri: params["error_uri"] + ) + )) + ) + } + + guard let code = params["code"] else { + return handler( + .failure(AuthgearError.oauthError( + OAuthError( + error: "invalid_request", + errorDescription: "Missing parameter: code", + errorUri: nil + ) + )) + ) + } + + do { + _ = try apiClient.syncRequestOIDCToken( + grantType: GrantType.settingsAction, + clientId: clientId, + deviceInfo: getDeviceInfo(), + redirectURI: redirectURI, + code: code, + codeVerifier: verifier.value, + codeChallenge: nil, + codeChallengeMethod: nil, + refreshToken: nil, + jwt: nil, + accessToken: nil, + xApp2AppDeviceKeyJwt: nil + ) + handler(.success(())) + } catch { + return handler(.failure(wrapError(error: error))) + } + } + + public func changePassword( + uiLocales: [String]? = nil, + colorScheme: ColorScheme? = nil, + wechatRedirectURI: String? = nil, + redirectURI: String, + handler: VoidCompletionHandler? = nil + ) { + openSettingsAction( + redirectURI: redirectURI, + action: .changePassword, + uiLocales: uiLocales, + colorScheme: colorScheme, + wechatRedirectURI: wechatRedirectURI, + handler: handler + ) + } + private func shouldRefreshAccessToken() -> Bool { // 1. We must have refresh token. guard refreshToken != nil else { return false } diff --git a/Sources/AuthgearExperimental.swift b/Sources/AuthgearExperimental.swift index 0a522c6e..3085d562 100644 --- a/Sources/AuthgearExperimental.swift +++ b/Sources/AuthgearExperimental.swift @@ -10,7 +10,7 @@ public struct AuthgearExperimental { } public func generateURL(redirectURI: String, handler: URLCompletionHandler?) { - self.authgear.generateURL(redirectURI: redirectURI, handler: handler) + self.authgear.generateURL(redirectURI: redirectURI, responseType: .none, handler: handler) } public func createAuthenticateRequest( diff --git a/example/ios_example/App.swift b/example/ios_example/App.swift index 9699f7cd..33258662 100644 --- a/example/ios_example/App.swift +++ b/example/ios_example/App.swift @@ -3,6 +3,7 @@ import SwiftUI class App: ObservableObject { static let redirectURI = "com.authgear.example://host/path" + static let app2appRedirectURI = "https://authgear-demo.pandawork.com/app2app/redirect" static let app2appAuthorizeEndpoint = "https://authgear-demo.pandawork.com/app2app/authorize" static let wechatUniversalLink = "https://authgear-demo.pandawork.com/wechat/" @@ -233,6 +234,21 @@ class App: ObservableObject { ) } + func changePassword() { + container?.changePassword( + colorScheme: self.colorScheme, + wechatRedirectURI: App.wechatRedirectURI, + redirectURI: App.redirectURI + ) { result in + switch result { + case .success: + self.successAlertMessage = "Changed password successfully" + case let .failure(error): + self.setError(error) + } + } + } + func promoteAnonymousUser() { container?.promoteAnonymousUser( redirectURI: App.redirectURI, diff --git a/example/ios_example/ContentView.swift b/example/ios_example/ContentView.swift index bc44cb70..b285625b 100644 --- a/example/ios_example/ContentView.swift +++ b/example/ios_example/ContentView.swift @@ -272,6 +272,12 @@ struct ActionButtonList: View { ActionButton(text: "Open Setting") }.disabled(!configured || !loggedIn || isAnonymous) + Button(action: { + self.app.changePassword() + }) { + ActionButton(text: "Change Password") + }.disabled(!configured || !loggedIn || isAnonymous) + Button(action: { self.app.showAuthTime() }) {