Monday, January 12, 2015

Sharing Keychain access in a Share Extension

I've been wanted to do a blog post on how to achieve SSO on iOS using sharing Keychain for a bit...

And at the same time, I also wanted to try app extension very badly. So in an attempt to get the best of the two worlds, let's talk about writing a share extension to an app which need to store OAuth2 access token in a secure manner. We'll see how to share Keychain content through group-id between an app and its extension.

Remember Shoot'nShare app?
A simple app that takes pictures and allows you to share them with Facebook, GoogleDrive or even your own Keycloak backend. If we want to learn more about it, visit previous blog posts: To simplify, in this blog post we will focus on sharing to Google Drive only. As a pre-requisite, let's start creating a Google project.

OAuth2 Google Set up


If you want to create a google project to use for uploading files to Google Drive, follow the steps below:
  • Have a Google account
  • Go to Google cloud console, create a new project
  • Go to APIs & auth menu, then select APIs and turn on Drive API
  • Always in APIs & auth menu, select Credentials and hit create new client id button Select iOS client and enter your bundle id.
  • NOTES: Enter a correct bundle id as it will be use in URL schema to specify the callback URL. Please use your own unique BUNDLE_ID with format like org.YOUR_DOMAIN.Shoot replacing YOUR_DOMAIN with your actual domain.
Once completed you will have your information displayed as below:

Now that we've got your google project set up, let's add an Share Extension to Shoot app and see what's involved.

Share Extension


What it is?

An App extension add feature to an existing application. There are several types of extensions. The one we're interested in today is the share extensions. As the name says it all, this extension lets you share content with the external world. By default Xcode template will inherit from SLComposeServiceViewController. Therefore when hitting share button, a pop-up appears to send a message with image. Before iOS8, only a handset of providers were available to share content with. Those providers were defined directly in the operating system directly so the list was not flexible at all. Those days are over (yay!), you can now share with your favourite or even your own social networks directly from Photos app. This is exactly what we're going to do: let's share to GoogleDrive from Photos app via Shoot'nShare app.

One important thing to bear in mind extensions are not deployed by themselves. They must be packaged within a container app. Concretely in Xcode extensions are extension target within you container app.

Let's see an example

1. Get the project
Code source can be found in aerogear-ios-cookbook app.extension branch. Clone the repo and select the correct branch:
git clone git@github.com:aerogear/aerogear-ios-cookbook.git
git checkout AGIOS-224.shoot-extension
2. Define you own bundle_id
To be able to work with extension you need to enable App Groups. App Groups are closely linked to bundle identifiers. So let's change the BUNDLE_ID of the project to match your name. Select the Shoot project in the Project Navigator, and then select the Shoot target from the list of targets. On the General tab, update the Bundle Identifier to org.YOUR_DOMAIN.Shoot replacing YOUR_DOMAIN with your actual domain. Do the same for the extension target: select the Shoot project in the Project Navigator and then select the ShootExt target. On the General tab, update the Bundle Identifier to org.YOUR_DOMAIN.Shoot.ShootExt replacing YOUR_DOMAIN with your actual domain.

3. Configure App Group for Shoot target
In order for Shoot'nShare to share content with its extension, you’ll need to set up an App Group. App Groups allow access to group containers that are shared amongst related apps, or in this case your container app and extension. Select the Shoot project, switch to the Capabilities tab and enable App Groups by flicking the switch. Add a new group, name it group.org.YOUR_DOMAIN.Shoot, again replacing YOUR_DOMAIN with your actual domain.

4. Configure your App Group for ShootExt target
Open the Capabilities tab and enable App Groups. Select the group you created when setting up the Shoot project. The App Group simply allows both the extension and container app to share files. This is important because of the way files are uploaded when using the extension. Before uploading, image files are saved to the shared container. Then, they are scheduled for upload via a background task.

Sharing Keychain


With the same idea of sharing group between apps (or app and extension) to be able to have a common space for saving files, we can use Keychain group so that app and extension can share Keychain items. In our case we want a common space for Shoot app and Shoot Ext to share OAuth2 access token.

1. Configure Keychain Sharing for Shoot target
In order for Shoot'nShare to share access tokens with its extensions, you’ll need to set up a Keychain Sharing Group. Select the Shoot project in the Project Navigator, and then select the Shoot target from the list of targets. Now switch to the Capabilities tab and enable Keychain Sharing by flicking the switch. Add a new group, name it org.YOUR_DOMAIN.Shoot, again replacing YOUR_DOMAIN with your actual domain.

2. Configure Keychain Sharing for ShootExt target
Select the Shoot project in the Project Navigator and then select the ShootExt target. Open the Capabilities tab and enable Keychain Sharing. Select the group you created when setting up the Shoot project.

3. Configure Shoot App code
In Shoot/ViewController.swift modify:
    @IBAction func shareWithGoogleDrive() {

        let googleConfig = GoogleConfig(
            clientId: "YOUR_GOOGLE_APP_ID.apps.googleusercontent.com",
            scopes:["https://www.googleapis.com/auth/drive"])
        let ssoKeychainGroup = "YOUR_APP_ID_PREFIX.org.YOUR_DOMAIN.Shoot"
...
where YOUR_APP_ID_PREFIX is a unique alphanumeric identifier, you can view it on dev center:
and org.YOUR_DOMAIN.Shoot is you BUNDLE_ID.

4. Configure ShootExt code
In ShootExt/ViewController.swift modify:
    let ssoKeychainGroup = "357BX7TCT5.org.corinne.Shoot"
    let appGroup = "group.org.corinne.Shoot"

    override func didSelectPost() {      
        // We can not use googleconfig as per default it take your ext bundle id, here we want to takes shoot app bundle id for redirect_uri
        let googleConfig = Config(base: "https://accounts.google.com",
                authzEndpoint: "o/oauth2/auth",
                redirectURL: "org.YOUR_DOMAIN.Shoot:/oauth2Callback",
                accessTokenEndpoint: "o/oauth2/token",
                clientId: "YOUR_GOOGLE_APP_ID.apps.googleusercontent.com",
                refreshTokenEndpoint: "o/oauth2/token",
                revokeTokenEndpoint: "rest/revoke",
                scopes:["https://www.googleapis.com/auth/drive"])"
...
Replace:
the constant ssoKeychainGroup with your YOUR_APP_ID_PREFIX + BUNDLE_ID.
the constant appGroup with your App Group
in google config, redirectURL should match your BUNDLE_ID

5. Run the extension
To run shoot extension, select ShootExt target and run it, select Photos app as host app.
Select a photo, click on share button and select Shoot app. A Pop-up will appear, select send: you photo is uploaded on the background... and we're done. We've done all the configuration needed. Let's look at the code now.

Spot the Difference


Actually what we want to do from Share extension is basically the same as we do from Shoot'nShare app. But we do in an extension to allow us to do from Photos app. What about playing the difference game? What are the differences between uploading from Shoot'nShare app or uploading from ShootExt?

1. you can not trigger the OAuth2 danse from the extension
Extensions have limitations. Some API are not available. An app extension cannot access a sharedApplication object, and so cannot use any of the methods on that object. Difficult to trigger an external browser to launch the OAuth2 danse. Opening the container app in case no access tokens is available could be an alternative... However this alternative is offered only for today widget extension...

Indeed depending on extension type, some actions are allowed or forbidden. For example quoting apple doc : "only a today widget (and no other app extension type) can ask the system to open its containing app by calling the openURL:completionHandler: method of the NSExtensionContext class."

With our ShootExt, it would have been handy to be able to open Shoot'nShare app if no access token is available in the shared keychain. As our extension is a share extension, this is not available. As a result, we take as a pre-requisite that the end user has already shared a photo from Shoot'nShare app before using the extension. To do so we override OAuth2Module's requestAuthorizationCode method:
public class OAuth2ModuleExtension: OAuth2Module {
    // For extension we do not want to be redirected to browser to authenticate
    // As a pre-requisite we should have a valid access_token stored in Keychain
    override public func requestAuthorizationCode(completionHandler: (AnyObject?, NSError?) -> Void) {
        completionHandler("NO_TOKEN", nil)
    }
}
In case there is no token, we will return an error message to the end user asking him to use Shoot'nShare first.

2. you use the same redirect-uri OAuth2 for both extension and app
To do so, in ShootExt/ViewController.swift we can not use GoogleConfig class, we'll have to use Config class ans spscify shoot'nShare's redirect url as shown below:
        let googleConfig = Config(base: "https://accounts.google.com",
                authzEndpoint: "o/oauth2/auth",
                redirectURL: "org.YOUR_DOMAIN.Shoot:/oauth2Callback",
                accessTokenEndpoint: "o/oauth2/token",
                clientId: "YOUR_GOOGLE_APP_ID.apps.googleusercontent.com",
                refreshTokenEndpoint: "o/oauth2/token",
                revokeTokenEndpoint: "rest/revoke",
                scopes:["https://www.googleapis.com/auth/drive"])


3. you need to save in the Keychain using group-id
When we first save the access token in Shoot'nShare app, we need to specified the group-id, in Shoot/Viewcontroller.swift, we modify shareWithGoogleDrive method to accomodate it: In line7-10 we create a TrustedPersistantOAuth2Session object with a keychain group-id:
    @IBAction func shareWithGoogleDrive() {
         let googleConfig = GoogleConfig(
            clientId: "YOUR_GOOGLE_APP_ID.apps.googleusercontent.com",
            scopes:["https://www.googleapis.com/auth/drive"])
        let ssoKeychainGroup = "YOUR_APP_ID_PREFIX.org.YOUR_DOMAIN.Shoot"
        // We specify the keychain groupId, should be the same as the one used in Share extension
        let gdModule = OAuth2Module(config: googleConfig, 
                                    session: TrustedPersistantOAuth2Session(accountId: 
                                               "ACCOUNT_FOR_CLIENTID_\(googleConfig.clientId)", 
                                               groupId: ssoKeychainGroup))
        self.http.authzModule = gdModule
        self.performUpload("https://www.googleapis.com/upload/drive/v2/files", parameters: self.extractImageAsMultipartParams())
    }


4. you upload your photo in the background
Last but not least, when dealing with extension, remember that action will take place in the background! In our case we want to perform a multipart upload (we're using multipart Google endpoint) in the background. using aerogear-ios-http you can perform multipart background upload either using upload method with stream or file as shown line 28. It's also possible to use a POST method with multipart params (behind the scene a NSURSLSession upload is performed):
    override func didSelectPost() {
        let googleConfig = ....
        
        // Create a TrustedPersistantOAuth2Session with a groupId for keychain group sharing
        let gdModule = OAuth2ModuleExtension(config: googleConfig, session: 
TrustedPersistantOAuth2Session(accountId: "ACCOUNT_FOR_CLIENTID_\(googleConfig.clientId)", 
groupId: ssoKeychainGroup))
        
        self.http.authzModule = gdModule
        gdModule.requestAccess { (response: AnyObject?, error: NSError?) -> Void in
            var accessToken = response as? String
            if accessToken == "NO_TOKEN" {
                println("You should go to Shoot app and grant oauth2 access")
            } else {
                let imageURL = self.saveImage(self.imageToShare!, name: NSUUID().UUIDString)
                // multipart upload
                let multiPartData = MultiPartData(url: imageURL!,
                            mimeType: "image/jpg")
                let parameters = ["file": multiPartData]
                // multi-part upload could be achievd either with upload as a stream or using POST
                self.http.upload("https://www.googleapis.com/upload/drive/v2/files", 
                         stream: NSInputStream(URL: imageURL!)!, 
                         parameters: parameters, 
                         method: .POST, 
                         progress: { (ar1:Int64, ar2:Int64, arr3:Int64) -> Void in
                    println("Uploading...")
                    }) { (response: AnyObject?, error: NSError?) -> Void in
                    println("Uploaded: \(response) \(error)")
                }

            }
        }
    }


Hope your find this blog post useful and remember: it's all about Sharing...
Do not hesitate to share this link :)

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.