= bodyStart + contentLength else {
+ return nil
+ }
+
+ let body = buffer.subdata(in: bodyStart..<(bodyStart + contentLength))
+ let targetURLString = target.hasPrefix("http") ? target : "http://127.0.0.1\(target)"
+ let components = URLComponents(string: targetURLString)
+ let path = routedPath(components?.path ?? "/")
+
+ var query: [String: [String]] = [:]
+ for item in components?.queryItems ?? [] {
+ query[item.name, default: []].append(item.value ?? "")
+ }
+
+ return HTTPRequest(
+ method: method,
+ target: target,
+ path: path,
+ query: query,
+ headers: headers,
+ body: body
+ )
+ }
+
+ private func route(_ request: HTTPRequest) -> HTTPResponse {
+ log(request)
+ if let queued = state.dequeue(method: request.method, path: request.path) {
+ return applyMockedOAuthDelayIfNeeded(
+ to: queuedResponse(from: queued),
+ for: request
+ )
+ }
+
+ let response: HTTPResponse
+ switch (request.method, request.path) {
+ case ("HEAD", "/test"), ("GET", "/test"):
+ response = textResponse(status: 200, body: "ok")
+ case ("GET", "/oauth/authorize"):
+ response = renderAuthorizePage(query: request.query)
+ case ("GET", "/oauth/account/social/success"):
+ response = handleSocialLoginSuccess(query: request.query)
+ case ("GET", "/oauth/prelogin"):
+ response = handleHostedPrelogin(query: request.query)
+ case ("POST", "/oauth/postlogin"):
+ response = handleHostedPostlogin(request)
+ case ("GET", "/oauth/postlogin/redirect"):
+ response = handleHostedPostloginRedirect(query: request.query)
+ case ("GET", "/idp/google/authorize"):
+ response = handleMockGoogleAuthorize(query: request.query)
+ case ("GET", "/embedded/continue"):
+ response = renderEmbeddedContinue(query: request.query)
+ case ("POST", "/embedded/password"):
+ response = completeEmbeddedPassword(request)
+ case ("GET", "/browser/complete"):
+ response = completeBrowserFlow(query: request.query)
+ case ("GET", "/dashboard"):
+ response = handleDashboard()
+ case ("POST", "/oauth/token"):
+ response = handleOAuthToken(request)
+ case ("POST", "/frontegg/oauth/authorize/silent"):
+ response = handleSilentAuthorize(request)
+ case ("GET", "/flags"):
+ response = handleFeatureFlags()
+ case ("GET", "/frontegg/flags"):
+ response = handleFeatureFlags()
+ case ("GET", "/frontegg/metadata"):
+ response = handleMetadata()
+ case ("GET", "/vendors/public"), ("GET", "/frontegg/vendors/public"):
+ response = handlePublicVendors()
+ case ("GET", "/frontegg/identity/resources/sso/v2"):
+ response = handleSocialLoginConfig()
+ case ("GET", "/frontegg/identity/resources/configurations/v1/public"):
+ response = handlePublicConfiguration()
+ case ("GET", "/frontegg/identity/resources/configurations/v1/auth/strategies/public"):
+ response = handleAuthStrategies()
+ case ("GET", "/frontegg/identity/resources/configurations/v1/sign-up/strategies"):
+ response = handleSignUpStrategies()
+ case ("GET", "/frontegg/team/resources/sso/v2/configurations/public"):
+ response = handleTeamSSOConfigurations()
+ case ("GET", "/identity/resources/sso/custom/v1"):
+ response = handleCustomSocialLoginConfig()
+ case ("GET", "/frontegg/identity/resources/sso/custom/v1"):
+ response = handleCustomSocialLoginConfig()
+ case ("GET", "/identity/resources/configurations/sessions/v1"):
+ response = handleSessionConfiguration()
+ case ("GET", "/frontegg/identity/resources/configurations/v1/captcha-policy/public"):
+ response = handleCaptchaPolicy()
+ case ("POST", "/frontegg/identity/resources/auth/v1/user/token/refresh"):
+ response = handleHostedRefresh(request)
+ case ("POST", "/frontegg/identity/resources/auth/v2/user/sso/prelogin"):
+ response = handleHostedSSOPrelogin(request)
+ case ("POST", "/frontegg/identity/resources/auth/v1/user"):
+ response = handleHostedPasswordLogin(request)
+ case ("GET", "/identity/resources/users/v2/me"):
+ response = handleMe(request)
+ case ("GET", "/identity/resources/users/v3/me/tenants"):
+ response = handleTenants(request)
+ case ("POST", "/oauth/logout/token"):
+ response = handleLogout(request)
+ default:
+ response = jsonResponse(status: 404, payload: ["error": "Unhandled route \(request.method) \(request.path)"])
+ }
+
+ return applyMockedOAuthDelayIfNeeded(to: response, for: request)
+ }
+
+ private func applyMockedOAuthDelayIfNeeded(
+ to response: HTTPResponse,
+ for request: HTTPRequest
+ ) -> HTTPResponse {
+ guard isMockedOAuthFlowRequest(request) else {
+ return response
+ }
+
+ guard response.delayMs == 0 else {
+ return response
+ }
+
+ return HTTPResponse(
+ statusCode: response.statusCode,
+ headers: response.headers,
+ body: response.body,
+ delayMs: Int.random(in: mockedOAuthDelayRangeMs),
+ closeConnection: response.closeConnection
+ )
+ }
+
+ private func isMockedOAuthFlowRequest(_ request: HTTPRequest) -> Bool {
+ switch (request.method, request.path) {
+ case ("GET", "/oauth/authorize"),
+ ("GET", "/oauth/account/social/success"),
+ ("GET", "/oauth/prelogin"),
+ ("POST", "/oauth/postlogin"),
+ ("GET", "/oauth/postlogin/redirect"),
+ ("GET", "/idp/google/authorize"),
+ ("POST", "/oauth/token"),
+ ("POST", "/frontegg/oauth/authorize/silent"),
+ ("POST", "/frontegg/identity/resources/auth/v1/user/token/refresh"),
+ ("POST", "/frontegg/identity/resources/auth/v2/user/sso/prelogin"):
+ return true
+ default:
+ return false
+ }
+ }
+
+ private func renderAuthorizePage(query: [String: [String]]) -> HTTPResponse {
+ let redirectURI = firstValue(query, key: "redirect_uri")
+ let stateValue = firstValue(query, key: "state")
+ let clientId = firstValue(query, key: "client_id")
+ let loginAction = firstValue(query, key: "login_direct_action")
+ let loginHint = firstValue(query, key: "login_hint")
+
+ if !loginAction.isEmpty {
+ let decodedAction = decodeBase64URLJSON(loginAction) ?? [:]
+ let destination = decodedAction["data"] as? String ?? ""
+
+ let title: String
+ let buttonTitle: String
+ let email: String
+
+ if destination.contains("custom-sso") {
+ title = "Custom SSO Mock Server"
+ buttonTitle = "Continue to Custom SSO"
+ email = "custom-sso@frontegg.com"
+ } else if destination.contains("mock-social-provider") {
+ title = "Mock Social Login"
+ buttonTitle = "Continue with Mock Social"
+ email = "social-login@frontegg.com"
+ } else {
+ title = "Direct Login Mock Server"
+ buttonTitle = "Continue"
+ email = "direct-login@frontegg.com"
+ }
+
+ let body = """
+ \(htmlEscaped(title))
+
+ """
+
+ return htmlResponse(status: 200, title: title, body: body)
+ }
+
+ let hostedState = state.issueHostedLoginContext(
+ redirectURI: redirectURI,
+ originalState: stateValue,
+ loginHint: loginHint
+ )
+
+ var components = URLComponents(url: currentAppBaseURL().appendingPathComponent("oauth/prelogin"), resolvingAgainstBaseURL: false)
+ components?.queryItems = [
+ URLQueryItem(name: "client_id", value: clientId),
+ URLQueryItem(name: "redirect_uri", value: redirectURI),
+ URLQueryItem(name: "state", value: hostedState),
+ ]
+ if !loginHint.isEmpty {
+ components?.queryItems?.append(URLQueryItem(name: "login_hint", value: loginHint))
+ }
+
+ return redirectResponse(location: components?.string ?? "\(currentAppBaseURL().absoluteString)/oauth/prelogin?state=\(hostedState)")
+ }
+
+ private func handleHostedPrelogin(query: [String: [String]]) -> HTTPResponse {
+ let hostedState = firstValue(query, key: "state")
+ guard let context = state.hostedLoginContext(for: hostedState) else {
+ return htmlResponse(status: 400, title: "Invalid hosted flow", body: "Invalid hosted flow
")
+ }
+
+ let email = firstValue(query, key: "email", default: context.loginHint)
+ if email.isEmpty {
+ return renderHostedEmailStep(hostedState: hostedState)
+ }
+
+ if email.hasSuffix("@saml-domain.com") {
+ return renderHostedProviderStep(
+ title: "OKTA SAML Mock Server",
+ buttonTitle: "Login With Okta",
+ hostedState: hostedState,
+ email: email
+ )
+ }
+
+ if email.hasSuffix("@oidc-domain.com") {
+ return renderHostedProviderStep(
+ title: "OKTA OIDC Mock Server",
+ buttonTitle: "Login With Okta",
+ hostedState: hostedState,
+ email: email
+ )
+ }
+
+ return renderHostedPasswordStep(
+ hostedState: hostedState,
+ email: email,
+ prefilledPassword: !context.loginHint.isEmpty
+ )
+ }
+
+ private func renderHostedEmailStep(hostedState: String) -> HTTPResponse {
+ let body = """
+ Mock Embedded Login
+
+
+ \(hostedBootstrapScript(includeRefreshAttempt: true))
+ """
+
+ return htmlResponse(status: 200, title: "Mock Embedded Login", body: body)
+ }
+
+ private func renderHostedPasswordStep(
+ hostedState: String,
+ email: String,
+ prefilledPassword: Bool
+ ) -> HTTPResponse {
+ let passwordValue = prefilledPassword ? #" value="Testpassword1!""# : ""
+ let hostedStateLiteral = javaScriptLiteral(hostedState)
+ let emailLiteral = javaScriptLiteral(email)
+ let autoSubmitScript = prefilledPassword
+ ? """
+ window.addEventListener('load', () => {
+ setTimeout(() => {
+ if (passwordField.value) {
+ form.requestSubmit();
+ }
+ }, 0);
+ });
+ """
+ : ""
+ let body = """
+ Password Login
+
+
+ """
+
+ return htmlResponse(status: 200, title: "Password Login", body: body)
+ }
+
+ private func renderHostedProviderStep(
+ title: String,
+ buttonTitle: String,
+ hostedState: String,
+ email: String
+ ) -> HTTPResponse {
+ let hostedStateLiteral = javaScriptLiteral(hostedState)
+ let emailLiteral = javaScriptLiteral(email)
+ let tokenPolicy = state.tokenPolicy(for: email)
+ let accessTokenLiteral = javaScriptLiteral(
+ accessToken(
+ email: email,
+ tokenVersion: tokenPolicy.startingTokenVersion,
+ expiresIn: tokenPolicy.accessTokenTTL
+ )
+ )
+ let body = """
+ \(htmlEscaped(title))
+
+
+ """
+
+ return htmlResponse(status: 200, title: title, body: body)
+ }
+
+ private func hostedBootstrapScript(includeRefreshAttempt: Bool) -> String {
+ let bootstrapURLs = [
+ "/vendors/public",
+ "/frontegg/metadata",
+ "/flags",
+ "/frontegg/flags",
+ "/frontegg/identity/resources/sso/v2",
+ "/frontegg/identity/resources/configurations/v1/public",
+ "/frontegg/identity/resources/configurations/v1/auth/strategies/public",
+ "/frontegg/identity/resources/configurations/v1/sign-up/strategies",
+ "/frontegg/team/resources/sso/v2/configurations/public",
+ "/frontegg/vendors/public",
+ "/frontegg/identity/resources/sso/custom/v1",
+ "/frontegg/identity/resources/configurations/v1/captcha-policy/public",
+ ]
+ let bootstrapList = bootstrapURLs
+ .map { "fetch(\(javaScriptLiteral($0))).catch(() => null)" }
+ .joined(separator: ",\n ")
+
+ let refreshCall = includeRefreshAttempt
+ ? """
+ fetch('/frontegg/identity/resources/auth/v1/user/token/refresh', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: '{}'
+ }).catch(() => null),
+ """
+ : ""
+
+ return """
+ window.addEventListener('load', () => {
+ Promise.allSettled([
+ \(refreshCall)
+ \(bootstrapList)
+ ]);
+ });
+ """
+ }
+
+ private func renderEmbeddedContinue(query: [String: [String]]) -> HTTPResponse {
+ let email = firstValue(query, key: "email", default: "test@frontegg.com")
+ let redirectURI = firstValue(query, key: "redirect_uri")
+ let stateValue = firstValue(query, key: "state")
+ return renderEmbeddedContinue(
+ email: email,
+ redirectURI: redirectURI,
+ stateValue: stateValue,
+ prefilledPassword: false
+ )
+ }
+
+ private func renderEmbeddedContinue(
+ email: String,
+ redirectURI: String,
+ stateValue: String,
+ prefilledPassword: Bool
+ ) -> HTTPResponse {
+
+ if email.hasSuffix("@saml-domain.com") {
+ let body = """
+ OKTA SAML Mock Server
+
+ """
+ return htmlResponse(status: 200, title: "OKTA SAML Mock Server", body: body)
+ }
+
+ if email.hasSuffix("@oidc-domain.com") {
+ let body = """
+ OKTA OIDC Mock Server
+
+ """
+ return htmlResponse(status: 200, title: "OKTA OIDC Mock Server", body: body)
+ }
+
+ let passwordValue = prefilledPassword ? #" value="Testpassword1!""# : ""
+ let body = """
+ Password Login
+
+ """
+ return htmlResponse(status: 200, title: "Password Login", body: body)
+ }
+
+ private func completeEmbeddedPassword(_ request: HTTPRequest) -> HTTPResponse {
+ let form = parseURLEncodedForm(data: request.body)
+ let email = form["email"] ?? "test@frontegg.com"
+ let redirectURI = form["redirect_uri"] ?? ""
+ let stateValue = form["state"] ?? ""
+ let code = state.issueCode(email: email, redirectURI: redirectURI, state: stateValue)
+ return redirectResponse(location: buildCallbackURL(redirectURI: redirectURI, code: code, state: stateValue))
+ }
+
+ private func completeBrowserFlow(query: [String: [String]]) -> HTTPResponse {
+ let email = firstValue(query, key: "email", default: "browser@frontegg.com")
+ let redirectURI = firstValue(query, key: "redirect_uri")
+ let stateValue = firstValue(query, key: "state")
+ let code = state.issueCode(email: email, redirectURI: redirectURI, state: stateValue)
+ return redirectResponse(location: buildCallbackURL(redirectURI: redirectURI, code: code, state: stateValue))
+ }
+
+ private func handleOAuthToken(_ request: HTTPRequest) -> HTTPResponse {
+ let body = parseJSONDictionary(request.body)
+ let grantType = body["grant_type"] as? String ?? ""
+
+ switch grantType {
+ case "authorization_code":
+ guard let code = body["code"] as? String, !code.isEmpty else {
+ return jsonResponse(status: 400, payload: ["error": "missing_code"])
+ }
+ guard let authCode = state.consumeCode(code) else {
+ return jsonResponse(status: 400, payload: ["error": "invalid_code"])
+ }
+ let issuedRefreshToken = state.issueRefreshToken(email: authCode.email)
+ return jsonResponse(
+ status: 200,
+ payload: authResponse(
+ session: issuedRefreshToken.record,
+ refreshToken: issuedRefreshToken.token
+ )
+ )
+
+ case "refresh_token":
+ guard let refreshToken = body["refresh_token"] as? String, !refreshToken.isEmpty else {
+ return jsonResponse(status: 400, payload: ["error": "missing_refresh_token"])
+ }
+ guard let session = state.refreshSession(for: refreshToken) else {
+ return jsonResponse(status: 401, payload: ["error": "invalid_refresh_token"])
+ }
+ return jsonResponse(
+ status: 200,
+ payload: authResponse(session: session, refreshToken: refreshToken)
+ )
+
+ default:
+ return jsonResponse(status: 400, payload: ["error": "unsupported_grant_type \(grantType)"])
+ }
+ }
+
+ private func handleSilentAuthorize(_ request: HTTPRequest) -> HTTPResponse {
+ guard let refreshToken = refreshTokenFromCookies(request.headers["cookie"]),
+ let session = state.validRefreshTokenRecord(for: refreshToken) else {
+ return jsonResponse(status: 401, payload: ["error": "invalid_refresh_cookie"])
+ }
+
+ return jsonResponse(
+ status: 200,
+ payload: authResponse(session: session, refreshToken: refreshToken)
+ )
+ }
+
+ private func handleFeatureFlags() -> HTTPResponse {
+ jsonResponse(status: 200, payload: [
+ "mobile-enable-logging": "off",
+ ])
+ }
+
+ private func handleMetadata() -> HTTPResponse {
+ jsonResponse(status: 200, payload: [
+ "appName": "demo-embedded-e2e",
+ "environment": "local",
+ ])
+ }
+
+ private func handlePublicVendors() -> HTTPResponse {
+ jsonResponse(status: 200, payload: [
+ "vendors": [],
+ ])
+ }
+
+ private func handleSocialLoginConfig() -> HTTPResponse {
+ jsonResponse(status: 200, payload: [
+ [
+ "type": "google",
+ "active": true,
+ "customised": false,
+ "clientId": "mock-google-client-id",
+ "redirectUrl": "\(currentAppBaseURL().absoluteString)/oauth/account/social/success",
+ "redirectUrlPattern": "\(currentAppBaseURL().absoluteString)/oauth/account/social/success",
+ "options": [
+ "verifyEmail": false,
+ ],
+ "additionalScopes": [],
+ ],
+ ])
+ }
+
+ private func handleCustomSocialLoginConfig() -> HTTPResponse {
+ jsonResponse(status: 200, payload: [
+ "providers": [],
+ ])
+ }
+
+ private func handlePublicConfiguration() -> HTTPResponse {
+ jsonResponse(status: 200, payload: [
+ "embeddedMode": true,
+ "loginBoxVisible": true,
+ ])
+ }
+
+ private func handleAuthStrategies() -> HTTPResponse {
+ jsonResponse(status: 200, payload: [
+ "password": true,
+ "socialLogin": true,
+ "sso": true,
+ ])
+ }
+
+ private func handleSignUpStrategies() -> HTTPResponse {
+ jsonResponse(status: 200, payload: [
+ "allowSignUp": true,
+ ])
+ }
+
+ private func handleTeamSSOConfigurations() -> HTTPResponse {
+ jsonResponse(status: 200, payload: [])
+ }
+
+ private func handleSessionConfiguration() -> HTTPResponse {
+ jsonResponse(status: 200, payload: [
+ "cookieName": "fe_refresh_demo_embedded_e2e",
+ "keepSessionAlive": true,
+ ])
+ }
+
+ private func handleCaptchaPolicy() -> HTTPResponse {
+ jsonResponse(status: 200, payload: [
+ "enabled": false,
+ ])
+ }
+
+ private func handleHostedRefresh(_ request: HTTPRequest) -> HTTPResponse {
+ guard let refreshToken = refreshTokenFromCookies(request.headers["cookie"]),
+ let session = state.refreshSession(for: refreshToken) else {
+ return jsonResponse(status: 401, payload: [
+ "errors": ["Session not found"],
+ ])
+ }
+
+ return jsonResponse(
+ status: 200,
+ payload: authResponse(session: session, refreshToken: refreshToken)
+ )
+ }
+
+ private func handleHostedSSOPrelogin(_ request: HTTPRequest) -> HTTPResponse {
+ let body = parseJSONDictionary(request.body)
+ let email = (body["email"] as? String ?? "").lowercased()
+
+ if email.hasSuffix("@saml-domain.com") {
+ return jsonResponse(status: 200, payload: [
+ "type": "saml",
+ "tenantId": tenantId(for: email),
+ ])
+ }
+
+ if email.hasSuffix("@oidc-domain.com") {
+ return jsonResponse(status: 200, payload: [
+ "type": "oidc",
+ "tenantId": tenantId(for: email),
+ ])
+ }
+
+ return jsonResponse(status: 404, payload: [
+ "errors": ["SSO domain was not found"],
+ ])
+ }
+
+ private func handleHostedPasswordLogin(_ request: HTTPRequest) -> HTTPResponse {
+ let body = parseJSONDictionary(request.body)
+ let email = body["email"] as? String ?? "test@frontegg.com"
+ let issuedRefreshToken = state.issueRefreshToken(email: email)
+
+ let authData = (try? JSONSerialization.data(
+ withJSONObject: authResponse(
+ session: issuedRefreshToken.record,
+ refreshToken: issuedRefreshToken.token
+ )
+ )) ?? Data("{}".utf8)
+ let cookieValue = "fe_refresh_demo_embedded_e2e=\(issuedRefreshToken.token); Path=/; HttpOnly; SameSite=Lax"
+ return HTTPResponse(
+ statusCode: 200,
+ headers: [
+ "Content-Type": "application/json; charset=utf-8",
+ "Set-Cookie": cookieValue,
+ ],
+ body: authData
+ )
+ }
+
+ private func handleHostedPostlogin(_ request: HTTPRequest) -> HTTPResponse {
+ let body = parseJSONDictionary(request.body)
+ let hostedState = body["state"] as? String ?? ""
+ guard let context = state.hostedLoginContext(for: hostedState) else {
+ return jsonResponse(status: 400, payload: [
+ "error": "invalid_state",
+ ])
+ }
+
+ let email: String
+ if let token = body["token"] as? String,
+ let resolvedEmail = emailFromBearerToken(token) {
+ email = resolvedEmail
+ } else if !context.loginHint.isEmpty {
+ email = context.loginHint
+ } else {
+ return jsonResponse(status: 400, payload: [
+ "error": "missing_token",
+ ])
+ }
+
+ let code = state.issueCode(email: email, redirectURI: context.redirectURI, state: context.originalState)
+ let redirectURL = buildCallbackURL(
+ redirectURI: context.redirectURI,
+ code: code,
+ state: context.originalState
+ )
+ state.recordHostedPostloginCompletion(hostedState: hostedState, email: email)
+ return jsonResponse(status: 200, payload: [
+ "redirectUrl": redirectURL,
+ ])
+ }
+
+ private func handleHostedPostloginRedirect(query: [String: [String]]) -> HTTPResponse {
+ let hostedState = firstValue(query, key: "state")
+ guard let context = state.hostedLoginContext(for: hostedState),
+ let email = state.completedHostedLoginEmail(for: hostedState) else {
+ return jsonResponse(status: 400, payload: [
+ "error": "missing_postlogin_completion",
+ ])
+ }
+
+ let code = state.issueCode(email: email, redirectURI: context.redirectURI, state: context.originalState)
+ return redirectResponse(location: buildCallbackURL(
+ redirectURI: context.redirectURI,
+ code: code,
+ state: context.originalState
+ ))
+ }
+
+ private func handleMockGoogleAuthorize(query: [String: [String]]) -> HTTPResponse {
+ let redirectURI = rewrittenGeneratedCallbackRedirectURI(firstValue(query, key: "redirect_uri"))
+ let stateValue = firstValue(query, key: "state")
+ guard !redirectURI.isEmpty, !stateValue.isEmpty else {
+ return htmlResponse(status: 400, title: "Invalid mock Google request", body: "Invalid mock Google request
")
+ }
+
+ let email = "google-social@frontegg.com"
+ let code = state.issueCode(email: email, redirectURI: redirectURI, state: stateValue)
+ let body = """
+ Mock Google Login
+ Fake Google account: \(htmlEscaped(email))
+
+ """
+
+ return htmlResponse(status: 200, title: "Mock Google Login", body: body)
+ }
+
+ private func handleSocialLoginSuccess(query: [String: [String]]) -> HTTPResponse {
+ let code = firstValue(query, key: "code")
+ let rawState = firstValue(query, key: "state")
+ guard state.authCode(for: code) != nil else {
+ return jsonResponse(status: 400, payload: ["error": "invalid_social_code"])
+ }
+
+ // First pass: provider redirected back to Frontegg inside ASWebAuthenticationSession.
+ // Redirect directly to the generated iOS callback URL so the session can close and
+ // the SDK can normalize the social callback back into /oauth/account/social/success
+ // inside the embedded webview, matching the production custom Google flow.
+ if firstValue(query, key: "redirectUri").isEmpty {
+ guard let socialState = decodeSocialState(rawState),
+ let bundleId = socialState["bundleId"] as? String,
+ !bundleId.isEmpty else {
+ return jsonResponse(status: 400, payload: ["error": "invalid_social_state"])
+ }
+
+ return redirectResponse(
+ location: buildGeneratedRedirectCodeCallbackURL(
+ bundleIdentifier: bundleId,
+ state: rawState,
+ code: code
+ )
+ )
+ }
+
+ // Second pass: embedded webview loads /oauth/account/social/success after callback
+ // normalization. Redirect to the generated embedded redirect URI with code and state,
+ // so the webview handles the hosted callback directly using the same path shape that
+ // SocialLoginUrlGenerator produces.
+ let redirectURI = firstValue(query, key: "redirectUri")
+ guard !redirectURI.isEmpty else {
+ return jsonResponse(status: 400, payload: ["error": "missing_social_redirect_uri"])
+ }
+
+ if state.consumeEmbeddedSocialSuccessDashboardRedirect() {
+ let issuedRefreshToken = state.issueRefreshToken(email: "google-social@frontegg.com")
+ let cookieValue = "fe_refresh_demo_embedded_e2e=\(issuedRefreshToken.token); Path=/; HttpOnly; SameSite=Lax"
+ return redirectResponse(
+ location: currentAppBaseURL().appendingPathComponent("dashboard").absoluteString,
+ additionalHeaders: ["Set-Cookie": cookieValue]
+ )
+ }
+
+ if state.consumeEmbeddedSocialSuccessRootRedirect() {
+ let issuedRefreshToken = state.issueRefreshToken(email: "google-social@frontegg.com")
+ let cookieValue = "fe_refresh_demo_embedded_e2e=\(issuedRefreshToken.token); Path=/; HttpOnly; SameSite=Lax"
+ return redirectResponse(
+ location: currentAppBaseURL().absoluteString,
+ additionalHeaders: ["Set-Cookie": cookieValue]
+ )
+ }
+
+ if state.consumeEmbeddedSocialSuccessStall() {
+ return HTTPResponse(
+ statusCode: 200,
+ headers: ["Content-Type": "text/html; charset=utf-8"],
+ body: Data("".utf8)
+ )
+ }
+
+ if let pendingError = state.consumeEmbeddedSocialSuccessOAuthError() {
+ let callbackState = state.latestHostedLoginState() ?? rawState
+ if let bundleId = embeddedBundleIdentifier(from: redirectURI) {
+ return redirectResponse(
+ location: buildGeneratedRedirectCallbackURL(
+ bundleIdentifier: bundleId,
+ state: callbackState,
+ error: pendingError.errorCode,
+ errorDescription: pendingError.errorDescription
+ )
+ )
+ }
+
+ return redirectResponse(
+ location: buildCallbackURL(
+ redirectURI: redirectURI,
+ state: callbackState,
+ error: pendingError.errorCode,
+ errorDescription: pendingError.errorDescription
+ )
+ )
+ }
+
+ return redirectResponse(
+ location: buildCallbackURL(
+ redirectURI: redirectURI,
+ code: code,
+ state: rawState
+ )
+ )
+ }
+
+ private func embeddedBundleIdentifier(from redirectURI: String) -> String? {
+ guard let components = URLComponents(string: redirectURI) else {
+ return nil
+ }
+
+ let prefix = "/oauth/account/redirect/ios/"
+ guard components.path.hasPrefix(prefix) else {
+ return nil
+ }
+
+ let suffix = components.path.dropFirst(prefix.count)
+ let bundleId = suffix.split(separator: "/").first.map(String.init) ?? ""
+ return bundleId.isEmpty ? nil : bundleId
+ }
+
+ private func handleDashboard() -> HTTPResponse {
+ htmlResponse(status: 200, title: "Dashboard", body: "Dashboard
")
+ }
+
+ private func handleMe(_ request: HTTPRequest) -> HTTPResponse {
+ guard let email = emailFromAuthorizationHeader(request.headers["authorization"]) else {
+ return jsonResponse(status: 401, payload: ["error": "missing_access_token"])
+ }
+
+ return jsonResponse(status: 200, payload: userResponse(email: email))
+ }
+
+ private func handleTenants(_ request: HTTPRequest) -> HTTPResponse {
+ guard let email = emailFromAuthorizationHeader(request.headers["authorization"]) else {
+ return jsonResponse(status: 401, payload: ["error": "missing_access_token"])
+ }
+
+ let tenant = tenantResponse(email: email)
+ return jsonResponse(status: 200, payload: [
+ "tenants": [tenant],
+ "activeTenant": tenant,
+ ])
+ }
+
+ private func handleLogout(_ request: HTTPRequest) -> HTTPResponse {
+ if let refreshToken = refreshTokenFromCookies(request.headers["cookie"]) {
+ state.invalidateRefreshToken(refreshToken)
+ }
+
+ return jsonResponse(status: 200, payload: ["ok": true])
+ }
+
+ private func send(_ response: HTTPResponse, for request: HTTPRequest, on connection: NWConnection) {
+ let transmit = { [self] in
+ if response.closeConnection {
+ connection.cancel()
+ return
+ }
+
+ var headers = response.headers
+ headers["Connection"] = "close"
+ headers["Content-Length"] = headers["Content-Length"] ?? "\(response.body.count)"
+
+ var payload = Data()
+ let statusLine = "HTTP/1.1 \(response.statusCode) \(reasonPhrase(for: response.statusCode))\r\n"
+ payload.append(statusLine.data(using: .utf8)!)
+
+ for (key, value) in headers.sorted(by: { $0.key < $1.key }) {
+ let headerLine = "\(key): \(value)\r\n"
+ payload.append(headerLine.data(using: .utf8)!)
+ }
+ payload.append(Data("\r\n".utf8))
+
+ if request.method != "HEAD" && !response.body.isEmpty {
+ payload.append(response.body)
+ }
+
+ connection.send(content: payload, completion: .contentProcessed { _ in
+ connection.cancel()
+ })
+ }
+
+ if response.delayMs > 0 {
+ listenerQueue.asyncAfter(deadline: .now() + .milliseconds(response.delayMs), execute: transmit)
+ } else {
+ listenerQueue.async(execute: transmit)
+ }
+ }
+
+ private func queuedResponse(from spec: [String: Any]) -> HTTPResponse {
+ let delayMs = intValue(spec["delay_ms"])
+ let closeConnection = boolValue(spec["close_connection"])
+ var statusCode = intValue(spec["status"], default: 200)
+
+ var headers: [String: String] = [:]
+ if let rawHeaders = spec["headers"] as? [String: String] {
+ headers = rawHeaders
+ } else if let rawHeaders = spec["headers"] as? [String: Any] {
+ for (key, value) in rawHeaders {
+ headers[key] = String(describing: value)
+ }
+ }
+
+ if let redirect = spec["redirect"] as? String, !redirect.isEmpty {
+ headers["Location"] = redirect
+ if spec["status"] == nil {
+ statusCode = 302
+ }
+ }
+
+ var body = Data()
+ if let json = spec["json"], JSONSerialization.isValidJSONObject(json) {
+ body = (try? JSONSerialization.data(withJSONObject: json)) ?? Data()
+ headers["Content-Type"] = headers["Content-Type"] ?? "application/json; charset=utf-8"
+ } else if let stringBody = spec["body"] as? String {
+ body = Data(stringBody.utf8)
+ headers["Content-Type"] = headers["Content-Type"] ?? "text/plain; charset=utf-8"
+ }
+
+ return HTTPResponse(
+ statusCode: statusCode,
+ headers: headers,
+ body: body,
+ delayMs: delayMs,
+ closeConnection: closeConnection
+ )
+ }
+
+ private func htmlResponse(status: Int, title: String, body: String) -> HTTPResponse {
+ let page = """
+
+
+
+
+
+ \(htmlEscaped(title))
+
+
+
+ \(body)
+
+
+ """
+
+ return HTTPResponse(
+ statusCode: status,
+ headers: ["Content-Type": "text/html; charset=utf-8"],
+ body: Data(page.utf8)
+ )
+ }
+
+ private func jsonResponse(status: Int, payload: Any) -> HTTPResponse {
+ let body = (try? JSONSerialization.data(withJSONObject: payload)) ?? Data("{}".utf8)
+ return HTTPResponse(
+ statusCode: status,
+ headers: ["Content-Type": "application/json; charset=utf-8"],
+ body: body
+ )
+ }
+
+ private func textResponse(status: Int, body: String) -> HTTPResponse {
+ HTTPResponse(
+ statusCode: status,
+ headers: ["Content-Type": "text/plain; charset=utf-8"],
+ body: Data(body.utf8)
+ )
+ }
+
+ private func redirectResponse(
+ location: String,
+ additionalHeaders: [String: String] = [:]
+ ) -> HTTPResponse {
+ var headers = ["Location": location]
+ additionalHeaders.forEach { headers[$0.key] = $0.value }
+ return HTTPResponse(
+ statusCode: 302,
+ headers: headers,
+ body: Data()
+ )
+ }
+
+ private func buildCallbackURL(redirectURI: String, code: String, state: String) -> String {
+ guard var components = URLComponents(string: redirectURI) else {
+ return redirectURI
+ }
+
+ var queryItems = components.queryItems ?? []
+ queryItems.append(URLQueryItem(name: "code", value: code))
+ if !state.isEmpty {
+ queryItems.append(URLQueryItem(name: "state", value: state))
+ }
+ components.queryItems = queryItems
+ return components.string ?? redirectURI
+ }
+
+ private func buildCallbackURL(
+ redirectURI: String,
+ state: String,
+ error: String,
+ errorDescription: String
+ ) -> String {
+ guard var components = URLComponents(string: redirectURI) else {
+ return redirectURI
+ }
+
+ var queryItems = components.queryItems ?? []
+ queryItems.append(URLQueryItem(name: "error", value: error))
+ queryItems.append(URLQueryItem(name: "error_description", value: errorDescription))
+ if !state.isEmpty {
+ queryItems.append(URLQueryItem(name: "state", value: state))
+ }
+ components.queryItems = queryItems
+ return components.string ?? redirectURI
+ }
+
+ private func buildGeneratedRedirectCallbackURL(
+ bundleIdentifier: String,
+ state: String,
+ error: String,
+ errorDescription: String
+ ) -> String {
+ var components = URLComponents()
+ components.scheme = bundleIdentifier.lowercased()
+ components.host = baseURL.host
+ components.path = generatedRedirectCallbackPath()
+
+ var queryItems = [
+ URLQueryItem(name: "error", value: error),
+ URLQueryItem(name: "error_description", value: errorDescription)
+ ]
+ if !state.isEmpty {
+ queryItems.append(URLQueryItem(name: "state", value: state))
+ }
+ components.queryItems = queryItems
+ return components.string ?? "\(bundleIdentifier.lowercased())://\(baseURL.host ?? "")\(generatedRedirectCallbackPath())"
+ }
+
+ private func buildGeneratedRedirectCodeCallbackURL(
+ bundleIdentifier: String,
+ state: String,
+ code: String
+ ) -> String {
+ var components = URLComponents()
+ components.scheme = bundleIdentifier.lowercased()
+ components.host = baseURL.host
+ components.path = generatedRedirectCallbackPath()
+ components.queryItems = [
+ URLQueryItem(name: "state", value: state),
+ URLQueryItem(name: "code", value: code),
+ URLQueryItem(name: "social-login-callback", value: "true")
+ ]
+ return components.string ?? "\(bundleIdentifier.lowercased())://\(baseURL.host ?? "")\(generatedRedirectCallbackPath())"
+ }
+
+ private func parseJSONDictionary(_ data: Data) -> [String: Any] {
+ guard !data.isEmpty else { return [:] }
+ guard let object = try? JSONSerialization.jsonObject(with: data),
+ let dictionary = object as? [String: Any] else {
+ return [:]
+ }
+ return dictionary
+ }
+
+ private func parseURLEncodedForm(data: Data) -> [String: String] {
+ guard let bodyString = String(data: data, encoding: .utf8), !bodyString.isEmpty else {
+ return [:]
+ }
+
+ var values: [String: String] = [:]
+ for pair in bodyString.components(separatedBy: "&") {
+ guard !pair.isEmpty else { continue }
+ let parts = pair.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
+ let name = decodeFormComponent(String(parts[0]))
+ let value = parts.count > 1 ? decodeFormComponent(String(parts[1])) : ""
+ values[name] = value
+ }
+ return values
+ }
+
+ private func decodeFormComponent(_ raw: String) -> String {
+ raw.replacingOccurrences(of: "+", with: " ").removingPercentEncoding ?? raw
+ }
+
+ private func refreshTokenFromCookies(_ cookieHeader: String?) -> String? {
+ guard let cookieHeader else { return nil }
+ for segment in cookieHeader.components(separatedBy: ";") {
+ let chunk = segment.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard chunk.hasPrefix("fe_refresh_"), let separator = chunk.firstIndex(of: "=") else {
+ continue
+ }
+ let valueIndex = chunk.index(after: separator)
+ return String(chunk[valueIndex...])
+ }
+ return nil
+ }
+
+ private func emailFromAuthorizationHeader(_ header: String?) -> String? {
+ guard let header, header.hasPrefix("Bearer ") else { return nil }
+ let token = String(header.dropFirst("Bearer ".count)).trimmingCharacters(in: .whitespacesAndNewlines)
+ return emailFromBearerToken(token)
+ }
+
+ private func emailFromBearerToken(_ token: String) -> String? {
+ let parts = token.components(separatedBy: ".")
+ guard parts.count > 1, let payload = decodeBase64URLJSON(parts[1]) else { return nil }
+ return payload["email"] as? String
+ }
+
+ private func decodeBase64URLJSON(_ value: String) -> [String: Any]? {
+ guard !value.isEmpty else { return nil }
+ var normalized = value
+ .replacingOccurrences(of: "-", with: "+")
+ .replacingOccurrences(of: "_", with: "/")
+
+ let padding = (4 - normalized.count % 4) % 4
+ if padding > 0 {
+ normalized += String(repeating: "=", count: padding)
+ }
+
+ guard let decoded = Data(base64Encoded: normalized),
+ let object = try? JSONSerialization.jsonObject(with: decoded),
+ let dictionary = object as? [String: Any] else {
+ return nil
+ }
+ return dictionary
+ }
+
+ private func decodeSocialState(_ rawState: String) -> [String: Any]? {
+ guard let data = rawState.data(using: .utf8),
+ let object = try? JSONSerialization.jsonObject(with: data),
+ let dictionary = object as? [String: Any] else {
+ return nil
+ }
+ return dictionary
+ }
+
+ private func encodeBase64URLJSON(_ value: [String: Any]) -> String {
+ let data = (try? JSONSerialization.data(withJSONObject: value)) ?? Data("{}".utf8)
+ return data.base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+
+ private func accessToken(
+ email: String,
+ tokenVersion: Int,
+ expiresIn: Int
+ ) -> String {
+ let now = Int(Date().timeIntervalSince1970)
+ let payload: [String: Any] = [
+ "sub": "user-\(email.components(separatedBy: "@").first ?? "demo")",
+ "email": email,
+ "name": userName(from: email),
+ "tenantId": tenantId(for: email),
+ "tenantIds": [tenantId(for: email)],
+ "profilePictureUrl": "https://example.com/avatar.png",
+ "exp": now + expiresIn,
+ "iat": now,
+ "token_version": tokenVersion,
+ ]
+
+ let header = encodeBase64URLJSON(["alg": "none", "typ": "JWT"])
+ let body = encodeBase64URLJSON(payload)
+ return "\(header).\(body).signature"
+ }
+
+ private func authResponse(
+ session: RefreshTokenRecord,
+ refreshToken: String
+ ) -> [String: Any] {
+ let policy = state.tokenPolicy(for: session.email)
+ let accessToken = accessToken(
+ email: session.email,
+ tokenVersion: session.tokenVersion,
+ expiresIn: policy.accessTokenTTL
+ )
+ return [
+ "token_type": "Bearer",
+ "refresh_token": refreshToken,
+ "access_token": accessToken,
+ "id_token": accessToken,
+ ]
+ }
+
+ private func tenantResponse(email: String) -> [String: Any] {
+ let tenantId = tenantId(for: email)
+ let now = "2026-03-26T00:00:00.000Z"
+ return [
+ "id": tenantId,
+ "name": "\(userName(from: email)) Tenant",
+ "tenantId": tenantId,
+ "createdAt": now,
+ "updatedAt": now,
+ "isReseller": false,
+ "metadata": "{}",
+ "vendorId": "vendor-demo",
+ ]
+ }
+
+ private func userResponse(email: String) -> [String: Any] {
+ let tenant = tenantResponse(email: email)
+ return [
+ "id": "user-\(email.components(separatedBy: "@").first ?? "demo")",
+ "email": email,
+ "mfaEnrolled": false,
+ "name": userName(from: email),
+ "profilePictureUrl": "https://example.com/avatar.png",
+ "phoneNumber": NSNull(),
+ "profileImage": NSNull(),
+ "roles": [],
+ "permissions": [],
+ "tenantId": tenant["id"] as Any,
+ "tenantIds": [tenant["id"] as Any],
+ "tenants": [tenant],
+ "activeTenant": tenant,
+ "activatedForTenant": true,
+ "metadata": "{}",
+ "verified": true,
+ "superUser": false,
+ ]
+ }
+
+ private func userName(from email: String) -> String {
+ let localPart = email.components(separatedBy: "@").first ?? email
+ let words = localPart
+ .replacingOccurrences(of: "-", with: " ")
+ .replacingOccurrences(of: ".", with: " ")
+ .split(separator: " ")
+ .map { $0.capitalized }
+ return words.isEmpty ? "Demo User" : words.joined(separator: " ")
+ }
+
+ private func tenantId(for email: String) -> String {
+ let localPart = email.components(separatedBy: "@").first ?? "demo"
+ let normalized = localPart
+ .replacingOccurrences(of: ".", with: "-")
+ .replacingOccurrences(of: "_", with: "-")
+ return "tenant-\(normalized)"
+ }
+
+ private func firstValue(_ query: [String: [String]], key: String, default defaultValue: String = "") -> String {
+ query[key]?.first ?? defaultValue
+ }
+
+ private func normalizeBasePathPrefix(_ path: String) -> String {
+ guard !path.isEmpty, path != "/" else { return "" }
+
+ var normalized = path
+ if !normalized.hasPrefix("/") {
+ normalized = "/\(normalized)"
+ }
+
+ while normalized.count > 1 && normalized.hasSuffix("/") {
+ normalized.removeLast()
+ }
+
+ return normalized
+ }
+
+ private func configuredAppBaseURL(basePathPrefix: String) -> URL {
+ guard !basePathPrefix.isEmpty else {
+ return baseURL
+ }
+
+ var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
+ components?.path = basePathPrefix
+ return components?.url ?? baseURL
+ }
+
+ private func currentAppBaseURL() -> URL {
+ configurationLock.lock()
+ let basePathPrefix = configuredBasePathPrefix
+ configurationLock.unlock()
+ return configuredAppBaseURL(basePathPrefix: basePathPrefix)
+ }
+
+ private func normalizePath(_ path: String) -> String {
+ if path.isEmpty { return "/" }
+ if path.hasPrefix("/") { return path }
+ return "/\(path)"
+ }
+
+ private func routedPath(_ rawPath: String) -> String {
+ let normalized = normalizePath(rawPath)
+ configurationLock.lock()
+ let basePathPrefix = configuredBasePathPrefix
+ configurationLock.unlock()
+
+ guard !basePathPrefix.isEmpty else {
+ return normalized
+ }
+
+ if normalized == basePathPrefix {
+ return "/"
+ }
+
+ let prefixWithSlash = "\(basePathPrefix)/"
+ if normalized.hasPrefix(prefixWithSlash) {
+ return "/\(normalized.dropFirst(prefixWithSlash.count))"
+ }
+
+ return normalized
+ }
+
+ private func generatedRedirectCallbackPath() -> String {
+ configurationLock.lock()
+ let configuration = (configuredBasePathPrefix, useRootGeneratedCallbackAlias)
+ configurationLock.unlock()
+
+ if !configuration.0.isEmpty && !configuration.1 {
+ return "\(configuration.0)/ios/oauth/callback"
+ }
+
+ return "/ios/oauth/callback"
+ }
+
+ private func rewrittenGeneratedCallbackRedirectURI(_ redirectURI: String) -> String {
+ configurationLock.lock()
+ let configuration = (configuredBasePathPrefix, useRootGeneratedCallbackAlias)
+ configurationLock.unlock()
+
+ guard configuration.1, !configuration.0.isEmpty else {
+ return redirectURI
+ }
+
+ guard var components = URLComponents(string: redirectURI) else {
+ return redirectURI
+ }
+
+ let canonicalPath = "\(configuration.0)/ios/oauth/callback"
+ guard components.path == canonicalPath else {
+ return redirectURI
+ }
+
+ components.path = "/ios/oauth/callback"
+ return components.string ?? redirectURI
+ }
+
+ private func htmlEscaped(_ string: String) -> String {
+ string
+ .replacingOccurrences(of: "&", with: "&")
+ .replacingOccurrences(of: "\"", with: """)
+ .replacingOccurrences(of: "'", with: "'")
+ .replacingOccurrences(of: "<", with: "<")
+ .replacingOccurrences(of: ">", with: ">")
+ }
+
+ private func javaScriptLiteral(_ string: String) -> String {
+ let data = (try? JSONSerialization.data(withJSONObject: [string])) ?? Data(#"[""]"#.utf8)
+ let json = String(data: data, encoding: .utf8) ?? #"[""]"#
+ return String(json.dropFirst().dropLast())
+ }
+
+ private func intValue(_ value: Any?, default defaultValue: Int = 0) -> Int {
+ if let intValue = value as? Int { return intValue }
+ if let number = value as? NSNumber { return number.intValue }
+ if let string = value as? String, let intValue = Int(string) { return intValue }
+ return defaultValue
+ }
+
+ private func boolValue(_ value: Any?) -> Bool {
+ if let boolValue = value as? Bool { return boolValue }
+ if let number = value as? NSNumber { return number.boolValue }
+ if let string = value as? String { return NSString(string: string).boolValue }
+ return false
+ }
+
+ private func reasonPhrase(for statusCode: Int) -> String {
+ switch statusCode {
+ case 200: return "OK"
+ case 201: return "Created"
+ case 204: return "No Content"
+ case 302: return "Found"
+ case 400: return "Bad Request"
+ case 401: return "Unauthorized"
+ case 403: return "Forbidden"
+ case 404: return "Not Found"
+ case 408: return "Request Timeout"
+ case 429: return "Too Many Requests"
+ case 500: return "Internal Server Error"
+ case 502: return "Bad Gateway"
+ case 503: return "Service Unavailable"
+ default: return HTTPURLResponse.localizedString(forStatusCode: statusCode).capitalized
+ }
+ }
+
+ private func log(_ request: HTTPRequest) {
+ let logged = LoggedRequest(method: request.method, path: request.path, target: request.target)
+ requestLogLock.lock()
+ requestLog.append(logged)
+ requestLogLock.unlock()
+ print("MockAuthServer \(logged.method) \(logged.target)")
+ }
+
+ private func hasRequest(method: String?, path: String) -> Bool {
+ requestLogLock.lock()
+ defer { requestLogLock.unlock() }
+ return requestLog.contains {
+ $0.path == path && (method == nil || $0.method == method)
+ }
+ }
+}
+
+private struct LoggedRequest {
+ let method: String
+ let path: String
+ let target: String
+}
+
+private struct HTTPRequest {
+ let method: String
+ let target: String
+ let path: String
+ let query: [String: [String]]
+ let headers: [String: String]
+ let body: Data
+}
+
+private struct HTTPResponse {
+ let statusCode: Int
+ let headers: [String: String]
+ let body: Data
+ let delayMs: Int
+ let closeConnection: Bool
+
+ init(
+ statusCode: Int,
+ headers: [String: String],
+ body: Data,
+ delayMs: Int = 0,
+ closeConnection: Bool = false
+ ) {
+ self.statusCode = statusCode
+ self.headers = headers
+ self.body = body
+ self.delayMs = delayMs
+ self.closeConnection = closeConnection
+ }
+}
+
+private struct AuthCode {
+ let email: String
+ let redirectURI: String
+ let state: String
+}
+
+private struct HostedLoginContext {
+ let redirectURI: String
+ let originalState: String
+ let loginHint: String
+}
+
+private struct PendingEmbeddedSocialSuccessOAuthError {
+ let errorCode: String
+ let errorDescription: String
+}
+
+private struct TokenPolicy {
+ let accessTokenTTL: Int
+ let refreshTokenTTL: Int
+ let startingTokenVersion: Int
+
+ static let `default` = TokenPolicy(
+ accessTokenTTL: 3600,
+ refreshTokenTTL: 24 * 3600,
+ startingTokenVersion: 1
+ )
+}
+
+private struct RefreshTokenRecord {
+ let email: String
+ let expiresAt: TimeInterval
+ var tokenVersion: Int
+}
+
+private struct IssuedRefreshToken {
+ let token: String
+ let record: RefreshTokenRecord
+}
+
+private final class MockAuthState {
+ private let queue = DispatchQueue(label: "com.frontegg.demo-embedded-e2e.mock-state")
+ private var queuedResponses: [String: [[String: Any]]] = [:]
+ private var authCodes: [String: AuthCode] = [:]
+ private var hostedLoginContexts: [String: HostedLoginContext] = [:]
+ private var latestHostedLoginStateValue: String?
+ private var completedHostedLogins: [String: String] = [:]
+ private var refreshTokens: [String: RefreshTokenRecord] = [:]
+ private var tokenPolicies: [String: TokenPolicy] = [:]
+ private var pendingEmbeddedSocialSuccessOAuthError: PendingEmbeddedSocialSuccessOAuthError?
+ private var pendingEmbeddedSocialSuccessStallCount: Int = 0
+ private var pendingEmbeddedSocialSuccessDashboardRedirectCount: Int = 0
+ private var pendingEmbeddedSocialSuccessRootRedirectCount: Int = 0
+
+ init() {
+ reset()
+ }
+
+ func reset() {
+ queue.sync {
+ queuedResponses = [:]
+ authCodes = [:]
+ hostedLoginContexts = [:]
+ latestHostedLoginStateValue = nil
+ completedHostedLogins = [:]
+ tokenPolicies = [:]
+ refreshTokens = [
+ "signup-refresh-token": RefreshTokenRecord(
+ email: "signup@frontegg.com",
+ expiresAt: Date().timeIntervalSince1970 + TimeInterval(TokenPolicy.default.refreshTokenTTL),
+ tokenVersion: TokenPolicy.default.startingTokenVersion
+ ),
+ ]
+ pendingEmbeddedSocialSuccessOAuthError = nil
+ pendingEmbeddedSocialSuccessStallCount = 0
+ pendingEmbeddedSocialSuccessDashboardRedirectCount = 0
+ pendingEmbeddedSocialSuccessRootRedirectCount = 0
+ }
+ }
+
+ func enqueue(method: String, path: String, responses: [[String: Any]]) {
+ let key = queueKey(method: method, path: path)
+ queue.sync {
+ queuedResponses[key, default: []].append(contentsOf: responses)
+ }
+ }
+
+ func dequeue(method: String, path: String) -> [String: Any]? {
+ let key = queueKey(method: method, path: path)
+ return queue.sync {
+ guard var responses = queuedResponses[key], !responses.isEmpty else {
+ return nil
+ }
+ let response = responses.removeFirst()
+ if responses.isEmpty {
+ queuedResponses.removeValue(forKey: key)
+ } else {
+ queuedResponses[key] = responses
+ }
+ return response
+ }
+ }
+
+ func issueCode(email: String, redirectURI: String, state: String) -> String {
+ queue.sync {
+ let code = "code-\(UUID().uuidString.lowercased())"
+ authCodes[code] = AuthCode(email: email, redirectURI: redirectURI, state: state)
+ return code
+ }
+ }
+
+ func issueHostedLoginContext(
+ redirectURI: String,
+ originalState: String,
+ loginHint: String
+ ) -> String {
+ queue.sync {
+ let hostedState = "hosted-\(UUID().uuidString.lowercased())"
+ hostedLoginContexts[hostedState] = HostedLoginContext(
+ redirectURI: redirectURI,
+ originalState: originalState,
+ loginHint: loginHint
+ )
+ latestHostedLoginStateValue = hostedState
+ return hostedState
+ }
+ }
+
+ func hostedLoginContext(for hostedState: String) -> HostedLoginContext? {
+ queue.sync {
+ hostedLoginContexts[hostedState]
+ }
+ }
+
+ func recordHostedPostloginCompletion(hostedState: String, email: String) {
+ queue.sync {
+ completedHostedLogins[hostedState] = email
+ }
+ }
+
+ func completedHostedLoginEmail(for hostedState: String) -> String? {
+ queue.sync {
+ completedHostedLogins[hostedState]
+ }
+ }
+
+ func consumeCode(_ code: String) -> AuthCode? {
+ queue.sync {
+ let authCode = authCodes[code]
+ authCodes.removeValue(forKey: code)
+ return authCode
+ }
+ }
+
+ func authCode(for code: String) -> AuthCode? {
+ queue.sync {
+ authCodes[code]
+ }
+ }
+
+ func configureTokenPolicy(
+ email: String,
+ accessTokenTTL: Int,
+ refreshTokenTTL: Int,
+ startingTokenVersion: Int
+ ) {
+ queue.sync {
+ tokenPolicies[email.lowercased()] = TokenPolicy(
+ accessTokenTTL: accessTokenTTL,
+ refreshTokenTTL: refreshTokenTTL,
+ startingTokenVersion: startingTokenVersion
+ )
+ }
+ }
+
+ func tokenPolicy(for email: String) -> TokenPolicy {
+ queue.sync {
+ tokenPolicyLocked(for: email)
+ }
+ }
+
+ func issueRefreshToken(email: String) -> IssuedRefreshToken {
+ queue.sync {
+ let refreshToken = "refresh-\(UUID().uuidString.lowercased())"
+ let policy = tokenPolicyLocked(for: email)
+ let record = RefreshTokenRecord(
+ email: email,
+ expiresAt: Date().timeIntervalSince1970 + TimeInterval(policy.refreshTokenTTL),
+ tokenVersion: policy.startingTokenVersion
+ )
+ refreshTokens[refreshToken] = record
+ return IssuedRefreshToken(token: refreshToken, record: record)
+ }
+ }
+
+ func validRefreshTokenRecord(for refreshToken: String) -> RefreshTokenRecord? {
+ queue.sync {
+ validatedRefreshTokenRecordLocked(for: refreshToken)
+ }
+ }
+
+ func refreshSession(for refreshToken: String) -> RefreshTokenRecord? {
+ queue.sync {
+ guard var record = validatedRefreshTokenRecordLocked(for: refreshToken) else {
+ return nil
+ }
+ record.tokenVersion += 1
+ refreshTokens[refreshToken] = record
+ return record
+ }
+ }
+
+ func queueEmbeddedSocialSuccessOAuthError(
+ errorCode: String,
+ errorDescription: String
+ ) {
+ queue.sync {
+ pendingEmbeddedSocialSuccessOAuthError = PendingEmbeddedSocialSuccessOAuthError(
+ errorCode: errorCode,
+ errorDescription: errorDescription
+ )
+ }
+ }
+
+ func queueEmbeddedSocialSuccessStall(count: Int) {
+ queue.sync {
+ pendingEmbeddedSocialSuccessStallCount += count
+ }
+ }
+
+ func queueEmbeddedSocialSuccessDashboardRedirect(count: Int) {
+ guard count > 0 else { return }
+
+ queue.sync {
+ pendingEmbeddedSocialSuccessDashboardRedirectCount += count
+ }
+ }
+
+ func queueEmbeddedSocialSuccessRootRedirect(count: Int) {
+ guard count > 0 else { return }
+
+ queue.sync {
+ pendingEmbeddedSocialSuccessRootRedirectCount += count
+ }
+ }
+
+ func consumeEmbeddedSocialSuccessOAuthError() -> PendingEmbeddedSocialSuccessOAuthError? {
+ queue.sync {
+ let pendingError = pendingEmbeddedSocialSuccessOAuthError
+ pendingEmbeddedSocialSuccessOAuthError = nil
+ return pendingError
+ }
+ }
+
+ func consumeEmbeddedSocialSuccessStall() -> Bool {
+ queue.sync {
+ guard pendingEmbeddedSocialSuccessStallCount > 0 else {
+ return false
+ }
+
+ pendingEmbeddedSocialSuccessStallCount -= 1
+ return true
+ }
+ }
+
+ func consumeEmbeddedSocialSuccessDashboardRedirect() -> Bool {
+ queue.sync {
+ guard pendingEmbeddedSocialSuccessDashboardRedirectCount > 0 else {
+ return false
+ }
+
+ pendingEmbeddedSocialSuccessDashboardRedirectCount -= 1
+ return true
+ }
+ }
+
+ func consumeEmbeddedSocialSuccessRootRedirect() -> Bool {
+ queue.sync {
+ guard pendingEmbeddedSocialSuccessRootRedirectCount > 0 else {
+ return false
+ }
+
+ pendingEmbeddedSocialSuccessRootRedirectCount -= 1
+ return true
+ }
+ }
+
+ func latestHostedLoginState() -> String? {
+ queue.sync {
+ latestHostedLoginStateValue
+ }
+ }
+
+ func email(forRefreshToken refreshToken: String) -> String? {
+ queue.sync {
+ validatedRefreshTokenRecordLocked(for: refreshToken)?.email
+ }
+ }
+
+ func invalidateRefreshToken(_ refreshToken: String) {
+ _ = queue.sync {
+ refreshTokens.removeValue(forKey: refreshToken)
+ }
+ }
+
+ private func queueKey(method: String, path: String) -> String {
+ "\(method.uppercased()) \(normalizedPath(path))"
+ }
+
+ private func tokenPolicyLocked(for email: String) -> TokenPolicy {
+ tokenPolicies[email.lowercased()] ?? .default
+ }
+
+ private func validatedRefreshTokenRecordLocked(for refreshToken: String) -> RefreshTokenRecord? {
+ guard let record = refreshTokens[refreshToken] else {
+ return nil
+ }
+
+ guard record.expiresAt > Date().timeIntervalSince1970 else {
+ refreshTokens.removeValue(forKey: refreshToken)
+ return nil
+ }
+
+ return record
+ }
+
+ private func normalizedPath(_ path: String) -> String {
+ if path.isEmpty { return "/" }
+ if path.hasPrefix("/") { return path }
+ return "/\(path)"
+ }
+}
diff --git a/example/ios/ReactNativeExampleUITests/LoginViaEmailAndPasswordTest.swift b/example/ios/ReactNativeExampleUITests/LoginViaEmailAndPasswordTest.swift
new file mode 100644
index 0000000..e68dcfb
--- /dev/null
+++ b/example/ios/ReactNativeExampleUITests/LoginViaEmailAndPasswordTest.swift
@@ -0,0 +1,49 @@
+import XCTest
+
+final class LoginViaEmailAndPasswordTest: UITestCase {
+ func test_success_login_via_email_and_password() throws {
+ launchApp()
+ try loginWithPassword()
+ XCTAssertTrue(waitForText(ProcessInfo.processInfo.environment["LOGIN_EMAIL"] ?? "test@frontegg.com"))
+ logoutAndAssert()
+ }
+
+ func test_failure_login_via_wrong_password() throws {
+ let wrongPassword = ProcessInfo.processInfo.environment["LOGIN_WRONG_PASSWORD"]
+ guard let wrongPw = wrongPassword, !wrongPw.isEmpty else {
+ throw XCTSkip("LOGIN_WRONG_PASSWORD env var required")
+ }
+
+ launchApp()
+ waitFor(app.buttons["loginButton"]).tap()
+ acceptSystemDialogIfNeeded()
+
+ let webView = app.webViews.firstMatch
+ guard webView.waitForExistence(timeout: 30) else {
+ throw XCTSkip("WebView did not appear — ASWebAuthenticationSession may not be accessible on this OS version")
+ }
+
+ let emailField = webView.textFields.firstMatch
+ guard emailField.waitForExistence(timeout: 15) else {
+ XCTFail("Email field not found")
+ return
+ }
+ emailField.tap()
+ emailField.typeText(ProcessInfo.processInfo.environment["LOGIN_EMAIL"] ?? "test@frontegg.com")
+ webView.buttons["Continue"].tap()
+
+ let pw = webView.secureTextFields.firstMatch
+ guard pw.waitForExistence(timeout: 15) else {
+ XCTFail("Password field not found")
+ return
+ }
+ pw.tap()
+ pw.typeText(wrongPw)
+ webView.buttons["Sign in"].tap()
+
+ let error = webView.staticTexts
+ .containing(NSPredicate(format: "label CONTAINS[c] %@", "Incorrect"))
+ .firstMatch
+ XCTAssertTrue(error.waitForExistence(timeout: 15))
+ }
+}
diff --git a/example/ios/ReactNativeExampleUITests/LoginViaGoogleTest.swift b/example/ios/ReactNativeExampleUITests/LoginViaGoogleTest.swift
new file mode 100644
index 0000000..df69de5
--- /dev/null
+++ b/example/ios/ReactNativeExampleUITests/LoginViaGoogleTest.swift
@@ -0,0 +1,34 @@
+import XCTest
+
+/// Social login smoke test. Verifies the button is reachable and
+/// the app survives tapping it.
+final class LoginViaGoogleTest: UITestCase {
+ func test_social_login_button_is_reachable() throws {
+ launchApp()
+ let button = app.buttons["loginWithGoogleButton"]
+ waitFor(button, timeout: 10)
+ button.tap()
+
+ // The social flow opens ASWebAuthenticationSession.
+ // Dismiss the system consent sheet if it appears.
+ acceptSystemDialogIfNeeded(timeout: 5)
+
+ // Wait a moment for the flow to start, then navigate back.
+ RunLoop.current.run(until: Date().addingTimeInterval(3))
+
+ // Either we landed authenticated (mock handled it) or a webview opened.
+ // Press the device home button to dismiss any external browser sheet.
+ if !app.buttons["logoutButton"].exists && !app.buttons["loginButton"].exists {
+ // Try dismissing the Safari VC by pressing Cancel if available
+ let cancel = app.buttons["Cancel"]
+ if cancel.waitForExistence(timeout: 3) {
+ cancel.tap()
+ }
+ }
+
+ // App should still be alive — either authenticated or back on login
+ let alive = app.buttons["logoutButton"].waitForExistence(timeout: 5)
+ || app.buttons["loginButton"].waitForExistence(timeout: 5)
+ XCTAssertTrue(alive, "App should be responsive after social login attempt")
+ }
+}
diff --git a/example/ios/ReactNativeExampleUITests/LogoutTest.swift b/example/ios/ReactNativeExampleUITests/LogoutTest.swift
new file mode 100644
index 0000000..f35891c
--- /dev/null
+++ b/example/ios/ReactNativeExampleUITests/LogoutTest.swift
@@ -0,0 +1,10 @@
+import XCTest
+
+final class LogoutTest: UITestCase {
+ func test_success_logout() throws {
+ launchApp()
+ try loginWithPassword()
+ logoutAndAssert()
+ XCTAssertTrue(waitForText("Not Logged in"))
+ }
+}
diff --git a/example/ios/ReactNativeExampleUITests/PasskeysLoginTest.swift b/example/ios/ReactNativeExampleUITests/PasskeysLoginTest.swift
new file mode 100644
index 0000000..91b99d7
--- /dev/null
+++ b/example/ios/ReactNativeExampleUITests/PasskeysLoginTest.swift
@@ -0,0 +1,18 @@
+import XCTest
+
+final class PasskeysLoginTest: UITestCase {
+ func test_login_with_passkeys_button_is_reachable() throws {
+ launchApp()
+
+ let button = app.buttons["loginWithPasskeysButton"]
+ waitFor(button, timeout: 10)
+ button.tap()
+
+ let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
+ if springboard.buttons["Cancel"].waitForExistence(timeout: 3) {
+ springboard.buttons["Cancel"].tap()
+ }
+
+ XCTAssertTrue(app.buttons["loginButton"].waitForExistence(timeout: 10))
+ }
+}
diff --git a/example/ios/ReactNativeExampleUITests/PasskeysRegisterTest.swift b/example/ios/ReactNativeExampleUITests/PasskeysRegisterTest.swift
new file mode 100644
index 0000000..2c6c502
--- /dev/null
+++ b/example/ios/ReactNativeExampleUITests/PasskeysRegisterTest.swift
@@ -0,0 +1,20 @@
+import XCTest
+
+final class PasskeysRegisterTest: UITestCase {
+ func test_register_passkeys_button_is_reachable() throws {
+ launchApp()
+ try loginWithPassword()
+
+ let button = app.buttons["registerPasskeysButton"]
+ waitFor(button, timeout: 10)
+ button.tap()
+
+ let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
+ if springboard.buttons["Cancel"].waitForExistence(timeout: 3) {
+ springboard.buttons["Cancel"].tap()
+ }
+
+ XCTAssertTrue(app.buttons["logoutButton"].waitForExistence(timeout: 10))
+ logoutAndAssert()
+ }
+}
diff --git a/example/ios/ReactNativeExampleUITests/RefreshTokenTest.swift b/example/ios/ReactNativeExampleUITests/RefreshTokenTest.swift
new file mode 100644
index 0000000..e7d8228
--- /dev/null
+++ b/example/ios/ReactNativeExampleUITests/RefreshTokenTest.swift
@@ -0,0 +1,25 @@
+import XCTest
+
+final class RefreshTokenTest: UITestCase {
+ func test_refresh_token_rotates_access_token() throws {
+ launchApp()
+ try loginWithPassword()
+
+ let tokenLabel = app.staticTexts["accessTokenValue"]
+ let before = tokenLabel.label
+
+ app.buttons["refreshTokenButton"].tap()
+
+ let deadline = Date().addingTimeInterval(15)
+ var after = before
+ while Date() < deadline {
+ after = tokenLabel.label
+ if after != before { break }
+ RunLoop.current.run(until: Date().addingTimeInterval(0.25))
+ }
+
+ XCTAssertNotEqual(before, after, "Access token should change after refresh")
+ XCTAssertTrue(app.buttons["logoutButton"].exists)
+ logoutAndAssert()
+ }
+}
diff --git a/example/ios/ReactNativeExampleUITests/RequestAuthorizeTest.swift b/example/ios/ReactNativeExampleUITests/RequestAuthorizeTest.swift
new file mode 100644
index 0000000..1a37b88
--- /dev/null
+++ b/example/ios/ReactNativeExampleUITests/RequestAuthorizeTest.swift
@@ -0,0 +1,14 @@
+import XCTest
+
+final class RequestAuthorizeTest: UITestCase {
+ func test_request_authorize_keeps_user_authenticated() throws {
+ launchApp()
+ try loginWithPassword()
+
+ app.buttons["requestAuthorizeButton"].tap()
+ RunLoop.current.run(until: Date().addingTimeInterval(3))
+
+ XCTAssertTrue(app.buttons["logoutButton"].exists)
+ logoutAndAssert()
+ }
+}
diff --git a/example/ios/ReactNativeExampleUITests/SessionRestoreTest.swift b/example/ios/ReactNativeExampleUITests/SessionRestoreTest.swift
new file mode 100644
index 0000000..39dca0d
--- /dev/null
+++ b/example/ios/ReactNativeExampleUITests/SessionRestoreTest.swift
@@ -0,0 +1,15 @@
+import XCTest
+
+final class SessionRestoreTest: UITestCase {
+ func test_session_is_restored_after_relaunch() throws {
+ launchApp(resetState: true)
+ try loginWithPassword()
+
+ app.terminate()
+ launchApp(resetState: false)
+
+ waitFor(app.buttons["logoutButton"], timeout: 30)
+ XCTAssertTrue(waitForText(ProcessInfo.processInfo.environment["LOGIN_EMAIL"] ?? "test@frontegg.com"))
+ logoutAndAssert()
+ }
+}
diff --git a/example/ios/ReactNativeExampleUITests/SwitchTenantTest.swift b/example/ios/ReactNativeExampleUITests/SwitchTenantTest.swift
new file mode 100644
index 0000000..e41d682
--- /dev/null
+++ b/example/ios/ReactNativeExampleUITests/SwitchTenantTest.swift
@@ -0,0 +1,12 @@
+import XCTest
+
+final class SwitchTenantTest: UITestCase {
+ func test_success_tenant_switch() throws {
+ launchApp()
+ try loginWithPassword()
+ app.swipeUp()
+
+ XCTAssertTrue(waitForText("Tenants"), "Tenants section should be visible")
+ logoutAndAssert()
+ }
+}
diff --git a/example/ios/ReactNativeExampleUITests/UITestCase.swift b/example/ios/ReactNativeExampleUITests/UITestCase.swift
new file mode 100644
index 0000000..ddadf43
--- /dev/null
+++ b/example/ios/ReactNativeExampleUITests/UITestCase.swift
@@ -0,0 +1,169 @@
+import XCTest
+
+/// Base class for React Native example app UI tests.
+///
+/// Starts a `LocalMockAuthServer` once per test class. The app is launched
+/// with `frontegg-testing=true` so FronteggSwift reads `FronteggTest.plist`,
+/// and `FRONTEGG_E2E_BASE_URL` / `FRONTEGG_E2E_CLIENT_ID` redirect the SDK
+/// to the mock server at localhost:49381.
+///
+/// Login-dependent tests drive the hosted login form via
+/// ASWebAuthenticationSession. When `LOGIN_EMAIL` and `LOGIN_PASSWORD` env
+/// vars are set (CI / local `.env`), real Frontegg is used for the login
+/// WebView while the SDK itself talks to the mock server. When the vars are
+/// absent, login tests are skipped with `XCTSkip`.
+class UITestCase: XCTestCase {
+ static var server: LocalMockAuthServer!
+ var app: XCUIApplication!
+
+ // MARK: - Class lifecycle (mock server)
+
+ override class func setUp() {
+ super.setUp()
+ // Bind the mock server once for the whole (serial) test run. Re-creating it per test
+ // class rebinds the same fixed port while the previous NWListener.cancel() is still in
+ // flight, which intermittently fails with EADDRINUSE ("Address already in use") — see
+ // LoginViaGoogleTest / PasskeysLoginTest. Per-test state is cleared via reset() in
+ // setUpWithError(), so a single shared server is safe.
+ if server == nil {
+ server = try! LocalMockAuthServer()
+ }
+ }
+
+ override class func tearDown() {
+ // Intentionally keep the shared mock server bound for the entire run; the test process
+ // reclaims it on exit. (See setUp for why we don't stop and rebind per class.)
+ super.tearDown()
+ }
+
+ // MARK: - Per-test lifecycle
+
+ override func setUpWithError() throws {
+ continueAfterFailure = false
+ try Self.server.reset()
+ }
+
+ override func tearDownWithError() throws {
+ app?.terminate()
+ }
+
+ // MARK: - Launch
+
+ @discardableResult
+ func launchApp(resetState: Bool = true) -> XCUIApplication {
+ let app = XCUIApplication()
+ app.launchEnvironment = Self.server.launchEnvironment(
+ resetState: resetState,
+ useTestingWebAuthenticationTransport: false
+ )
+ app.launch()
+ self.app = app
+ return app
+ }
+
+ // MARK: - Waits
+
+ @discardableResult
+ func waitFor(
+ _ element: XCUIElement,
+ timeout: TimeInterval = 20,
+ file: StaticString = #filePath,
+ line: UInt = #line
+ ) -> XCUIElement {
+ XCTAssertTrue(
+ element.waitForExistence(timeout: timeout),
+ "Element did not appear: \(element)",
+ file: file, line: line
+ )
+ return element
+ }
+
+ func waitForText(_ text: String, timeout: TimeInterval = 20) -> Bool {
+ let predicate = NSPredicate(format: "label CONTAINS[c] %@", text)
+ let element = app.descendants(matching: .any).matching(predicate).firstMatch
+ return element.waitForExistence(timeout: timeout)
+ }
+
+ // MARK: - Login flow (via ASWebAuthenticationSession)
+
+ /// Taps the Login button, dismisses the ASWebAuth consent alert,
+ /// and fills the hosted login form with the provided credentials.
+ ///
+ /// Requires `LOGIN_EMAIL` and `LOGIN_PASSWORD` environment variables.
+ /// Throws `XCTSkip` if credentials are not available.
+ func loginWithPassword(
+ email: String? = nil,
+ password: String? = nil
+ ) throws {
+ let resolvedEmail = email ?? ProcessInfo.processInfo.environment["LOGIN_EMAIL"]
+ let resolvedPassword = password ?? ProcessInfo.processInfo.environment["LOGIN_PASSWORD"]
+
+ guard let loginEmail = resolvedEmail, !loginEmail.isEmpty,
+ let loginPassword = resolvedPassword, !loginPassword.isEmpty else {
+ throw XCTSkip("LOGIN_EMAIL and LOGIN_PASSWORD env vars required for login tests")
+ }
+
+ // Tap the RN app's Login button
+ waitFor(app.buttons["loginButton"]).tap()
+
+ // Handle ASWebAuthenticationSession consent alert
+ acceptSystemDialogIfNeeded()
+
+ // The hosted login opens in ASWebAuthenticationSession. On iOS 17+
+ // the webview content IS accessible via app.webViews.
+ let webView = app.webViews.firstMatch
+ guard webView.waitForExistence(timeout: 30) else {
+ XCTFail("Hosted login web view did not appear")
+ return
+ }
+
+ let emailField = webView.textFields.firstMatch
+ guard emailField.waitForExistence(timeout: 15) else {
+ XCTFail("Email field not found in hosted login")
+ return
+ }
+ emailField.tap()
+ emailField.typeText(loginEmail)
+
+ let continueBtn = webView.buttons["Continue"]
+ guard continueBtn.waitForExistence(timeout: 5) else {
+ XCTFail("Continue button not found")
+ return
+ }
+ continueBtn.tap()
+
+ let passwordField = webView.secureTextFields.firstMatch
+ guard passwordField.waitForExistence(timeout: 15) else {
+ XCTFail("Password field not found in hosted login")
+ return
+ }
+ passwordField.tap()
+ passwordField.typeText(loginPassword)
+
+ webView.buttons["Sign in"].tap()
+
+ // Wait until we're back on the native screen, authenticated.
+ waitFor(app.buttons["logoutButton"], timeout: 30)
+ }
+
+ func logoutAndAssert() {
+ app.buttons["logoutButton"].tap()
+ waitFor(app.buttons["loginButton"], timeout: 15)
+ }
+
+ func acceptSystemDialogIfNeeded(timeout: TimeInterval = 5) {
+ let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
+ let deadline = Date().addingTimeInterval(timeout)
+ let buttonTitles = ["Continue", "Open", "Allow", "OK"]
+ while Date() < deadline {
+ for title in buttonTitles {
+ let button = springboard.buttons[title]
+ if button.exists {
+ button.tap()
+ return
+ }
+ }
+ RunLoop.current.run(until: Date().addingTimeInterval(0.2))
+ }
+ }
+}
diff --git a/example/ios/add_e2e_files.rb b/example/ios/add_e2e_files.rb
new file mode 100644
index 0000000..2968ba0
--- /dev/null
+++ b/example/ios/add_e2e_files.rb
@@ -0,0 +1,57 @@
+#!/usr/bin/env ruby
+# Adds FronteggTest.plist to the app target's resources and
+# LocalMockAuthServer.swift to the UI test target's sources.
+# Idempotent — safe to run multiple times.
+
+require 'xcodeproj'
+
+PROJECT_PATH = File.join(__dir__, 'ReactNativeExample.xcodeproj')
+project = Xcodeproj::Project.open(PROJECT_PATH)
+
+app_target = project.targets.find { |t| t.name == 'ReactNativeExample' }
+ui_test_target = project.targets.find { |t| t.name == 'ReactNativeExampleUITests' }
+
+abort "Missing ReactNativeExample target" unless app_target
+abort "Missing ReactNativeExampleUITests target" unless ui_test_target
+
+# ── Add FronteggTest.plist to the app target ──
+plist_path = 'FronteggTest.plist'
+unless app_target.resources_build_phase.files.any? { |f| f.file_ref&.path == plist_path }
+ # Find or create file reference
+ main_group = project.main_group
+ plist_ref = main_group.files.find { |f| f.path == plist_path }
+ unless plist_ref
+ plist_ref = main_group.new_reference(plist_path)
+ end
+ app_target.resources_build_phase.add_file_reference(plist_ref)
+ puts "Added #{plist_path} to ReactNativeExample resources"
+else
+ puts "#{plist_path} already in ReactNativeExample resources"
+end
+
+# ── Add LocalMockAuthServer.swift to UI test target ──
+mock_server_name = 'LocalMockAuthServer.swift'
+ui_group = project.main_group.children.find { |g| g.display_name == 'ReactNativeExampleUITests' }
+
+if ui_group
+ existing = ui_group.files.find { |f| f.path == mock_server_name }
+ unless existing
+ ref = ui_group.new_reference(mock_server_name)
+ ui_test_target.source_build_phase.add_file_reference(ref)
+ puts "Added #{mock_server_name} to ReactNativeExampleUITests sources"
+ else
+ # Check if it's already in the build phase
+ already_in_phase = ui_test_target.source_build_phase.files.any? { |f| f.file_ref&.path == mock_server_name }
+ unless already_in_phase
+ ui_test_target.source_build_phase.add_file_reference(existing)
+ puts "Added existing #{mock_server_name} to build phase"
+ else
+ puts "#{mock_server_name} already in ReactNativeExampleUITests sources"
+ end
+ end
+else
+ puts "WARNING: ReactNativeExampleUITests group not found"
+end
+
+project.save
+puts "Done."
diff --git a/example/ios/add_uitest_target.rb b/example/ios/add_uitest_target.rb
new file mode 100644
index 0000000..8151a13
--- /dev/null
+++ b/example/ios/add_uitest_target.rb
@@ -0,0 +1,83 @@
+#!/usr/bin/env ruby
+# Adds the ReactNativeExampleUITests target to the Xcode project.
+# Requires the xcodeproj gem: `gem install xcodeproj`
+#
+# Usage: ruby add_uitest_target.rb
+#
+# This script is idempotent — running it twice is safe.
+
+require 'xcodeproj'
+
+PROJECT_PATH = File.join(__dir__, 'ReactNativeExample.xcodeproj')
+UI_TEST_DIR = File.join(__dir__, 'ReactNativeExampleUITests')
+TARGET_NAME = 'ReactNativeExampleUITests'
+APP_TARGET = 'ReactNativeExample'
+BUNDLE_ID = 'com.frontegg.demo.uitests'
+
+project = Xcodeproj::Project.open(PROJECT_PATH)
+
+# Idempotent: skip if already present
+if project.targets.any? { |t| t.name == TARGET_NAME }
+ puts "#{TARGET_NAME} target already exists — nothing to do."
+ exit 0
+end
+
+app_target = project.targets.find { |t| t.name == APP_TARGET }
+abort "Could not find #{APP_TARGET} target" unless app_target
+
+# Create the UI testing bundle target
+ui_test_target = project.new_target(
+ :ui_testing_bundle,
+ TARGET_NAME,
+ :ios,
+ nil, # deployment target — inherit from project
+ nil, # project
+ :swift
+)
+
+# Set the test host
+ui_test_target.add_dependency(app_target)
+
+# Add source files
+group = project.main_group.new_group(TARGET_NAME, UI_TEST_DIR)
+
+Dir.glob(File.join(UI_TEST_DIR, '*.swift')).sort.each do |path|
+ file_ref = group.new_reference(File.basename(path))
+ ui_test_target.source_build_phase.add_file_reference(file_ref)
+end
+
+# Add Info.plist
+info_plist = File.join(UI_TEST_DIR, 'Info.plist')
+if File.exist?(info_plist)
+ group.new_reference('Info.plist')
+end
+
+# Configure build settings
+ui_test_target.build_configurations.each do |config|
+ config.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = BUNDLE_ID
+ config.build_settings['INFOPLIST_FILE'] = "#{TARGET_NAME}/Info.plist"
+ config.build_settings['TEST_TARGET_NAME'] = APP_TARGET
+ config.build_settings['SWIFT_VERSION'] = '5.0'
+ config.build_settings['CODE_SIGN_STYLE'] = 'Automatic'
+ config.build_settings['LD_RUNPATH_SEARCH_PATHS'] = '$(inherited) @executable_path/Frameworks @loader_path/Frameworks'
+ # Xcode needs the bridging header to be nil for pure-Swift UI test targets
+ config.build_settings.delete('SWIFT_OBJC_BRIDGING_HEADER')
+end
+
+# Register in project attributes (TestTargetID for the test diamond icon)
+attrs = project.root_object.attributes['TargetAttributes'] || {}
+attrs[ui_test_target.uuid] = {
+ 'CreatedOnToolsVersion' => '15.0',
+ 'TestTargetID' => app_target.uuid,
+}
+project.root_object.attributes['TargetAttributes'] = attrs
+
+# Add to Products group
+products = project.main_group.children.find { |g| g.display_name == 'Products' }
+if products
+ products.children << ui_test_target.product_reference
+end
+
+project.save
+
+puts "Added #{TARGET_NAME} target to #{PROJECT_PATH}"
diff --git a/example/src/HomeScreen.tsx b/example/src/HomeScreen.tsx
index a52ac83..4a994d2 100644
--- a/example/src/HomeScreen.tsx
+++ b/example/src/HomeScreen.tsx
@@ -60,22 +60,37 @@ export default function HomeScreen() {
React Native Example
- showLoader: {state.showLoader ? 'true' : 'false'}
- initializing: {state.initializing ? 'true' : 'false'}
- isLoading: {state.isLoading ? 'true' : 'false'}
- isAuthenticated: {state.isAuthenticated ? 'true' : 'false'}
- Active Tenant: {state.user?.activeTenant.name}
- refreshToken: {state.refreshToken}
-
+
+ showLoader: {state.showLoader ? 'true' : 'false'}
+
+
+ initializing: {state.initializing ? 'true' : 'false'}
+
+
+ isLoading: {state.isLoading ? 'true' : 'false'}
+
+
+ isAuthenticated: {state.isAuthenticated ? 'true' : 'false'}
+
+
+ Active Tenant: {state.user?.activeTenant.name}
+
+
+ refreshToken: {state.refreshToken}
+
+
accessToken:{' '}
{state.accessToken
? state.accessToken.substring(state.accessToken.length - 40)
: ''}
- user: {state.user ? state.user.email : 'Not Logged in'}
+
+ user: {state.user ? state.user.email : 'Not Logged in'}
+
{
@@ -87,6 +102,7 @@ export default function HomeScreen() {
{state.isAuthenticated ? null : (
{
@@ -131,6 +147,7 @@ export default function HomeScreen() {
{state.isAuthenticated ? (
{
@@ -165,6 +182,7 @@ export default function HomeScreen() {
{state.isAuthenticated ? (
{
@@ -215,6 +234,7 @@ export default function HomeScreen() {
{
@@ -230,6 +250,7 @@ export default function HomeScreen() {
.map((tenant: ITenantsResponse) => (
- {}} />
+ {}}
+ />
diff --git a/example/src/components/FronteggButton.tsx b/example/src/components/FronteggButton.tsx
index f512947..b4bfcd1 100644
--- a/example/src/components/FronteggButton.tsx
+++ b/example/src/components/FronteggButton.tsx
@@ -11,6 +11,7 @@ export type FronteggButtonProps = {
variant?: ButtonVariant;
style?: StyleProp;
textStyle?: StyleProp;
+ testID?: string;
accessibilityLabel?: string;
};
@@ -30,6 +31,7 @@ export default function FronteggButton({
variant = 'primary',
style,
textStyle,
+ testID,
accessibilityLabel,
}: FronteggButtonProps) {
const isOutline = variant === 'outline';
@@ -58,6 +60,7 @@ export default function FronteggButton({
return (