Building a Single Sign-On Extension on iOS

Related Resources

Initial Setup

Project Setup 

A Single Sign On Extension requires a container app and a extension. When the app is installed, the extension is register to be called as needed. This section will show how to create the project, the app and the single sign on extension. The container app does not need to have any functionality related to Single Sign-On, but is required by the extension.

Step 1

Open Xcode and create a new project.

Step 2

Select the template type App for iOS.

Step 3

Name the app Scissors-ios and set up the app to use Swift and Storyboards.

Step 4

After the project window opens, the single sign-on extension must be added. Selece File->New-> Target…. Select Authentication Services under Application Extensions and select Next.

Step 5

Name the extension ssoe-ios and select Finish.

Step 6

Xcode will detect that an extension and offer to add a scheme for building the extension. Select Activate.

Step 7

As mentioned in the prior chapter, the Single Sign-On extension must be associated with a domain. This requires an Associated Domain entitled. Make sure the Scissors-ios target is selected and not the ssoe target and select Signing & Capabilites, then select + Capability.

Step 8

Select Associated Domains

Step 9

Under the new entry for Associated Domains, select + and enter “authsrv:idp.twocanoes.com?mode=developer”. As mentioned in the prior chapter, the extension does not check this hostname directly in production, but checks the Apple Content Distribution Network (CDN). During development, adding “?mode=developer” to the hostname will allow the extension to check the hostname directly.

Step 10

Developer mode must be activated for associated domains. Open the Settings app on the target iOS device and select Associate Domains Development under Developer. If you do not see the Developer option, see apple documentation for Enabling Developer Mode on a device.

Step 11

Determine the container app bundle identifier by selecting Build Settings in the container app target. In this tutorial, the bundle app ID is com.twocanoes.Scissors-ios. The container bundle app ID is required in the next step.

Step 12

In order for the extension to be activated, a file must be present at a specific location on the web server located at the domain name specified above (in this tutorial, “idp.twocanoes.com”). The file must be available at https://hostname/.well-known/apple-app-site-association and must contain very specific information. The entry under “apps” must be in the following format: TEAM_ID.CONTAINER_APP_BUNDLE_ID The Team ID can be found the developer portal and on the developer signing certificate. The Container App bundle identifier was discoverd in the prior step. If you are unsure of your team identifier, it can be verified in an upcoming step. Update the file on the webserver to match these values. You will need to work with the administrator of the server to upload this file.

Associated JSON File 

{
    authsrv =     {
        apps =         (
            "UXP6YEHSPW.com.twocanoes.Scissors",
        );
    };
}

Step 13

The file can be verified using the swcutil command as shown. The command swcutil requires administrator rights so the command must be run with the sudo command. Provide the “dl” option and the hostname of the identity provider. This must be the same as the hostname provided in step 8. If the Associated Domain file has been uploaded to the correct location on the webserver, the JSON file contents will be shown.

swcutil output 

mdscentral-2:~ tperfitt$ sudo swcutil  dl -d idp.twocanoes.com
Password:
{
    authsrv =     {
        apps =         (
            "UXP6YEHSPW.com.twocanoes.Scissors",
        );
    };
}

Step 14

A configuration profile must to deployed with an MDM service for the extension to be activated. Note the configuration profile cannot be manually installed.

Line 11 must be updated to match the bundle identifier of the extension. Line 28 must be updated to match the URL that will used caught and send to the extension. The hostname of URL must match the hostname in Step 8.

MDM Profile 

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>PayloadContent</key>
	<array>
		<dict>
			<key>AuthenticationMethod</key>
			<string>Password</string>
			<key>ExtensionIdentifier</key>
			<string>com.twocanoes.Scissors-ios.ssoe-ios</string>
			<key>PayloadDisplayName</key>
			<string>Single Sign-On Extensions</string>
			<key>PayloadIdentifier</key>
			<string>com.apple.extensiblessoD5AC0626-265B-406F-9497-567D8CD58B0A</string>
			<key>PayloadType</key>
			<string>com.apple.extensiblesso</string>
			<key>PayloadUUID</key>
			<string>CDC67F3E-0687-4796-95B0-A61EF6F3F9A7</string>
			<key>PayloadVersion</key>
			<integer>1</integer>
			<key>TeamIdentifier</key>
			<string>UXP6YEHSPW</string>
			<key>Type</key>
			<string>Redirect</string>
			<key>URLs</key>
			<array>
				<string>https://idp.twocanoes.com/realms/twocanoes/</string>
			</array>
		</dict>
	</array>
	<key>PayloadDisplayName</key>
	<string>SSOE</string>
	<key>PayloadIdentifier</key>
	<string>mdscentral.CDC67F3E-0687-4796-95B0-A61EF6F3F9A7</string>
	<key>PayloadScope</key>
	<string>System</string>
	<key>PayloadType</key>
	<string>Configuration</string>
	<key>PayloadUUID</key>
	<string>0DC6670F-F853-49CB-91B3-1C5ECB5D3F46</string>
	<key>PayloadVersion</key>
	<integer>1</integer>
</dict>
</plist>

Step 15

Upload the configuration profile to your MDM service and have MDM service send the configuration profile to the test iOS device. Verify the profile shows up in Settings with the correct settings.

Verifying Settings 

Now that the project is set up with the container app, the single sign-on extension, the app domain association file and the profile, it important to verify all settings are correct otherwise the extension will not get called. This section verifies the settings and tests the the extension will get called.

Step 1

Select the Scissors Scheme and your test iOS device as the Run Destination, and build the App by selecting Product->Run or clicking the Run button. The container app should show an empty window and not have any build errors.

Step 2

Find the path to the container app by selecting Build->Show Build Folder in Finder. This path will be used in the next step.

Step 3

Run the command codesign -dv <Path To App>. Verify the team id same as the team id added to the apple app site association file. If it is different, update the team id in the apple app site association file on the web server to match. Remember to re-recheck the file with scwutil after updating the file.

Team ID 

mdscentral-2:~ tperfitt$ codesign -dv /Users/tperfitt/Library/Developer/Xcode/DerivedData/Scissors-desvvwvwenmhnlflkmlezenmexpe/Build/Products/Debug/Scissors.app 
Executable=/Users/tperfitt/Library/Developer/Xcode/DerivedData/Scissors-desvvwvwenmhnlflkmlezenmexpe/Build/Products/Debug/Scissors.app/Contents/MacOS/Scissors
Identifier=com.twocanoes.Scissors
Format=app bundle with Mach-O thin (arm64)
CodeDirectory v=20500 size=898 flags=0x10000(runtime) hashes=17+7 location=embedded
Signature size=4796
Signed Time=Mar 29, 2024 at 12:27:00 PM
Info.plist entries=21
TeamIdentifier=UXP6YEHSPW
Runtime Version=14.4.0
Sealed Resources version=2 rules=13 files=6
Internal requirements count=1 size=188

Step 4

As a final check, open Console in macOS and select the iOS device. Start logging, and filter “SOExtensionManager”. Clean and run the project. Verify the log shows that your extension’s bundle ID shows the correct domain in the associatedDomains array.

Step 5

Set a breakpoint at the start of the beginAuthorization(request:) function.

Step 6

In order for this breakpoint to be stopped on, Xcode must be attached to the extension process when it dynamically launched by the system. To attach with Xcode, select the Debug menu, then Attach to Process by PID or Name. Enter in the name of the single sign-on extension. The name will be the same name as the target of the extension unless changed.

Step 7

Build and run the application. Open the Safari browser and go the URL defined in MDM configuration profile. In this example, the URL is https://idp.twocanoes.com/realms/twocanoes/. If everything is set up correctly, Xcode should stop at the breakpoint you set. If Xcode does not stop at the breakpoint, recheck your steps. Also, make sure that the ssoe process did not exit. If ssoe exits, you must attach to it again in Xcode.

Step 8

Click the stop button in Xcode to stop debugging the extension. You are now ready to start coding the extension!

Coding The Single Sign-On Extension iOS

Handling the Request 

The extension is now getting called when the specified redirect URL navigated to. Let’s add in some code to show a simple interface and handle the request. When the URL request is received, a web view is shown to authenticate the user and the a HTTP redirect is detected to end the authentication session. The cookies from that session are then saved to keychain and restored the next time the extension is called. If the session is still valid, the user will not be prompted to enter a password.

Step 1

Create a new file in the project by selecting File->New->File… Select Swift File, name the file “Cookie.swift”, make sure the file is included in extension target, and save it to the top level of the project.

Step 2

Add the shown code to save and restore cookies from the keychain. Make sure to add the file to the ssoe-ios target since the extension will be using these functions.

Cookies.swift 

//
//  Cookies.swift
//


import Foundation


let kService = "Cookie Cache"


func cookieHeaderString(from cookieArray: [HTTPCookie]) -> String {
    let dateFormatter = ISO8601DateFormatter.init()
    var cookieStringArray = [String]()
    var cookieString = [String]()


    for httpCookie in cookieArray {
        for (key,value) in
                [httpCookie.name:httpCookie.value,
                 "domain":httpCookie.domain,
                 "path":httpCookie.path,
                 "expires":dateFormatter.string(from:httpCookie.expiresDate ?? Date()),
                 "SameSite":httpCookie.sameSitePolicy?.rawValue ?? ""]
        {
            cookieString.append("\(key)=\(value)")
        }


        for (key,value) in
                ["secure":httpCookie.isSecure,
                "httponly":httpCookie.isHTTPOnly]
        {
            if value==true {
                cookieString.append(key)
            }
        }
        cookieStringArray.append(cookieString.joined(separator: "; "))
    }
    return cookieStringArray.joined(separator: ", ")
}




func storeCookiesInKeychain(_ cookies: [HTTPCookie] ) -> Bool  {
    do {
        let data = try NSKeyedArchiver.archivedData(withRootObject: cookies, requiringSecureCoding: false)


        let attributes = [kSecClass: kSecClassGenericPassword,
                    kSecAttrService: kService,
      kSecUseDataProtectionKeychain: false,
                      kSecValueData: data] as [String: Any]
        _ = SecItemDelete(attributes as CFDictionary)
        if SecItemAdd(attributes as CFDictionary, nil) == noErr {
            return true
        }
        print("error cookies to keychain")


    }
    catch {
        print(error.localizedDescription)
    }
    return false
}


func cookiesFromKeychain() -> [HTTPCookie]? {
    let attributes = [kSecClass: kSecClassGenericPassword,
                kSecAttrService: kService,
           kSecReturnAttributes: true,
  kSecUseDataProtectionKeychain: false,
                 kSecReturnData: true] as [String: Any]
    var item: CFTypeRef?
    if  SecItemCopyMatching(attributes as CFDictionary, &item) == 0,
        let result = item as? [String:AnyObject],
        let cookiesRaw = result[kSecValueData as String] as? Data,
        let cookies = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(cookiesRaw) as? [HTTPCookie],
        cookies.count>0 {
        return cookies
    }
    return nil
}



Step 3

The AuthenticationViewController.swift is the main class for the extension. It handles the user interface and the function func beginAuthorization(with request:) gets called when the configured URL in the MDM Profile is access. Instead of adding code directly to this class, code will be added to a Swift extension so that it can be reused between macOS and iOS (AuthenticationViewController inherits from a NSViewController on macOS and UIViewController on iOS).

Create a new file in the project by selecting File->New->File… Select Swift File, name the file “AuthenticationViewController+Shared.swift”, make sure it is included in the extension target, and save it to the top level of the project.

AuthenticationViewController+Shared.swift 

//
//  AuthenticationViewController+Shared.swift
//  ssoe-ios
//
//  Created by Timothy Perfitt on 4/5/24.
//


import Foundation

Step 4

Import AuthenticationServices and WebKit. Define 2 protocols: WebViewSSOProtocol (to handle the redirect and load the authentication view) and ExtensionAuthorizationRequestProtocol (to handle the authorization request).

AuthenticationViewController+Shared.swift 

//
//  AuthenticationViewController+Shared.swift
//  ssoe-ios
//
//  Created by Timothy Perfitt on 4/5/24.
//


import Foundation
import AuthenticationServices
import WebKit


protocol WebViewSSOProtocol {
	func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!)
	
	func setupWebViewAndDelegate()
}


protocol ExtensionAuthorizationRequestProtocol {
	func process(_ request:ASAuthorizationProviderExtensionAuthorizationRequest)
	
}

Step 5

Implement the extension AuthenticationViewController:WebViewSSOProtocol, WKNavigationDelegate including 2 functions: func setupWebViewAndDelegate() and func webView(webView:navigation:). The setupWebViewAndDelegate sets up the webview to get called when a redirect happens and populates the cookies from the keychain. This will restore any previous sessions.

AuthenticationViewController+Shared.swift 

//
//  AuthenticationViewController+Shared.swift
//  ssoe-ios
//
//  Created by Timothy Perfitt on 4/5/24.
//


import Foundation
import AuthenticationServices
import WebKit


protocol WebViewSSOProtocol {
	func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!)
	
	func setupWebViewAndDelegate()
}


protocol ExtensionAuthorizationRequestProtocol {
	func process(_ request:ASAuthorizationProviderExtensionAuthorizationRequest)
	
}


extension AuthenticationViewController:WebViewSSOProtocol, WKNavigationDelegate {
	
	func setupWebViewAndDelegate() {
		if let url = url {
			webView.navigationDelegate=self
			var request = URLRequest(url: url)
			let cookies = getCookies()
			
			if let cookies = cookies {
				request.setValue(combineCookies(cookies: cookies), forHTTPHeaderField: "Cookie")
			}
			request.httpShouldHandleCookies=true
			webView.load(request)
		}
	}
	
	public func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) {
		guard let url = url, let webViewURL = webView.url else {
			
			return
		}
		if (webViewURL.absoluteString.starts(with: (kCallbackURLString))) {
			webView.configuration.websiteDataStore.httpCookieStore.getAllCookies({ cookies in
				let headers: [String:String] = [
					"Location": webViewURL.absoluteString,
					"Set-Cookie": combineCookies(cookies: cookies)
				]
				storeCookies(cookies)
				if let response = HTTPURLResponse.init(url: url, statusCode: 302, httpVersion: nil, headerFields: headers) {
					self.authorizationRequest?.complete(httpResponse: response, httpBody: nil)
				}
			})
		}
		
	}
}

Step 6

Implement the extension AuthenticationViewController:ExtensionAuthorizationRequestProtocol including the process(request:) function. This function presents the view. Even if authentication is not needed, the view will be shown. If a session is restore and no user input is required, the direct will be called and the view will close.

AuthenticationViewController+Shared.swift 

//
//  AuthenticationViewController+Shared.swift
//  ssoe-ios
//
//  Created by Timothy Perfitt on 4/5/24.
//


import Foundation
import AuthenticationServices
import WebKit


protocol WebViewSSOProtocol {
	func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!)
	
	func setupWebViewAndDelegate()
}


protocol ExtensionAuthorizationRequestProtocol {
	func process(_ request:ASAuthorizationProviderExtensionAuthorizationRequest)
	
}


extension AuthenticationViewController:WebViewSSOProtocol, WKNavigationDelegate {
	
	func setupWebViewAndDelegate() {
		if let url = url {
			webView.navigationDelegate=self
			var request = URLRequest(url: url)
			let cookies = getCookies()
			
			if let cookies = cookies {
				request.setValue(combineCookies(cookies: cookies), forHTTPHeaderField: "Cookie")
			}
			request.httpShouldHandleCookies=true
			webView.load(request)
		}
	}
	
	public func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) {
		guard let url = url, let webViewURL = webView.url else {
			
			return
		}
		if (webViewURL.absoluteString.starts(with: (kCallbackURLString))) {
			webView.configuration.websiteDataStore.httpCookieStore.getAllCookies({ cookies in
				let headers: [String:String] = [
					"Location": webViewURL.absoluteString,
					"Set-Cookie": combineCookies(cookies: cookies)
				]
				storeCookies(cookies)
				if let response = HTTPURLResponse.init(url: url, statusCode: 302, httpVersion: nil, headerFields: headers) {
					self.authorizationRequest?.complete(httpResponse: response, httpBody: nil)
				}
			})
		}
		
	}
}
extension AuthenticationViewController:ExtensionAuthorizationRequestProtocol {
	
	func process(_ request:ASAuthorizationProviderExtensionAuthorizationRequest){
		url=request.url
		request.presentAuthorizationViewController(completion: { (success, error) in
			if error != nil {
				request.complete(error: error!)
			}
		})
	}
}
extension AuthenticationViewController: ASAuthorizationProviderExtensionAuthorizationRequestHandler {


    public func beginAuthorization(with request: ASAuthorizationProviderExtensionAuthorizationRequest) {
        self.authorizationRequest = request


        process(request)
    }
}



Step 7

Select AuthenticationViewController.swift.

AuthenticationViewController.swift 

//
//  AuthenticationViewController.swift
//  ssoe-ios
//
//  Created by Timothy Perfitt on 4/5/24.
//


import UIKit
import AuthenticationServices


class AuthenticationViewController: UIViewController {


    var authorizationRequest: ASAuthorizationProviderExtensionAuthorizationRequest?


    override func loadView() {
        super.loadView()
        // Do any additional setup after loading the view.
    }


    override var nibName: String? {
        return "AuthenticationViewController"
    }
}


extension AuthenticationViewController: ASAuthorizationProviderExtensionAuthorizationRequestHandler {


    public func beginAuthorization(with request: ASAuthorizationProviderExtensionAuthorizationRequest) {
        self.authorizationRequest = request


        // Call this to indicate immediate authorization succeeded.
        let authorizationHeaders = [String: String]() // TODO: Fill in appropriate authorization headers.
        request.complete(httpAuthorizationHeaders: authorizationHeaders)


        // Or present authorization view and call self.authorizationRequest.complete() later after handling interactive authorization.
        // request.presentAuthorizationViewController(completion: { (success, error) in
        //     if error != nil {
        //         request.complete(error: error!)
        //     }
        // })
    }
}

Step 8

Set the instance variables and include WebKit. Implement the cancel button to cancel the authentication when the cancel button is pressed.

AuthenticationViewController.swift 

//
//  AuthenticationViewController.swift
//  ssoe-ios
//
//  Created by Timothy Perfitt on 4/5/24.
//


import UIKit
import AuthenticationServices
import WebKit


class AuthenticationViewController: UIViewController {


    @IBOutlet weak var webView: WKWebView!
    var url:URL?
    var authorizationRequest: ASAuthorizationProviderExtensionAuthorizationRequest?


    @IBAction func cancelButtonPressed(_ sender: Any) {
        self.authorizationRequest?.doNotHandle()
    }


    override func loadView() {
        super.loadView()
        // Do any additional setup after loading the view.
    }


    override var nibName: String? {
        return "AuthenticationViewController"
    }
}


extension AuthenticationViewController: ASAuthorizationProviderExtensionAuthorizationRequestHandler {


    public func beginAuthorization(with request: ASAuthorizationProviderExtensionAuthorizationRequest) {
        self.authorizationRequest = request


        // Call this to indicate immediate authorization succeeded.
        let authorizationHeaders = [String: String]() // TODO: Fill in appropriate authorization headers.
        request.complete(httpAuthorizationHeaders: authorizationHeaders)


        // Or present authorization view and call self.authorizationRequest.complete() later after handling interactive authorization.
        // request.presentAuthorizationViewController(completion: { (success, error) in
        //     if error != nil {
        //         request.complete(error: error!)
        //     }
        // })
    }
}

Step 9

Update beginAuthorization(request:) to call the shared function. Also implement the cancelButtonPressed(sender:).

AuthenticationViewController.swift 

//
//  AuthenticationViewController.swift
//  ssoe-ios
//
//  Created by Timothy Perfitt on 4/5/24.
//


import UIKit
import AuthenticationServices
import WebKit


class AuthenticationViewController: UIViewController {


    @IBOutlet weak var webView: WKWebView!
    var url:URL?
    var authorizationRequest: ASAuthorizationProviderExtensionAuthorizationRequest?


    @IBAction func cancelButtonPressed(_ sender: Any) {
        self.authorizationRequest?.doNotHandle()
    }


    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        setupWebViewAndDelegate()
    }


    override var nibName: String? {
        return "AuthenticationViewController"
    }
}


extension AuthenticationViewController: ASAuthorizationProviderExtensionAuthorizationRequestHandler {


    public func beginAuthorization(with request: ASAuthorizationProviderExtensionAuthorizationRequest) {
        self.authorizationRequest = request


        process(request)
    }
}



Adding the WebView 

The webview is shown when a URL is caught to allow the user to authenticate.

Step 1

Open the AuthenticationViewController.xib and add a WebKitView and button as shown.

Step 2

Connect the button and the web view as shown.

Testing The Single Sign-On Extension

Testing 

The extension is now complete. The extension can be tested by navigating to a URL that was defined in the MDM configuration payload.

Step 1

Build and run the application. Open the Safari browser and go a URL that redirects to the Identity Provider to trigger a redirect to the URL defined in the MDM configuration profile. In this example, the URL is https://idp.twocanoes.com:9443. Safari will redirect to the identity provider and the web view will be shown. Log in to the web service using the username “jappleseed” and the password “twocanoes”.

Step 2

The extension will save the session cookies to the keychain and do a final redirect. The authentication should be successful. Any further attempts to access that same resources (with another web session or an app) will not require authentication.

Developing a Single Sign-In extension or Platform Single Sign-In extension for your organization and need some assistance? Get in touch!