SwiftUI Google OpenID Connect

I want to build small iOS app that will talk to Google API, so I need "Signin with Google" feature and oh my gosh it was so frustrating from beginning to end...

Majority of links everywhere talking about Firebase which is not something I want to bring, little bit less info is about official documented library here Google Sign-In for iOS and macOS

But it is not clear to me how it works, why changed user did not trigger view rerender, why profile is optional and so on and so on

Wish to save a note of how we can have google authentication without any third party libraries at all

How does it work

Before touching any Swift we need to understand few building blocks

After registering an app in Google we have our client id and so called ios url scheme - this one is so funy

Technically whenever we want to login user our job is to create an authorization link and open browser pointing to that link

User will authorize, and callback will be called - this callback will be not something like http://localhost/?foo=bar but our_custom_ios_scheme_url:/callback?foo=bar

Our app job is to register itself in system as a handler for such urls, so it will catch it, exchange tockens and draw successfull screen

Authorize endpoint

Authentication url formation is done like this:

## prerequisites
# random string, aka our secret we will use to verify later, look for "code_challange" note
secret=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 43)
# base64url(sha256(code_verifier))
hash=$(echo -n "$secret" | openssl sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=')

response_type='code'
client_id='98400000000-700000000000000000000000000000va4.apps.googleusercontent.com'
# note: redirect_uri is not usual url, it's a custom url scheme provided by Google, and its scheme MUST be exactly like that, part after `:/` can be anything, url will be handled by our app
redirect_uri='com.googleusercontent.apps.98400000000-700000000000000000000000000000va4:/callback'
scope='openid profile email'
# note: can be used to pass data between stages
state='HELLO'
# note: this one is like csrf token but inversed, on client side we are creating secret and hashing it with SHA256 to check later when callback will be called
code_challenge="$hash"
code_challenge_method='S256'

# open browser with autorization url, after logging in, user will be redirected to our app with code, state and the secret
open "https://accounts.google.com/o/oauth2/v2/auth?response_type=$response_type&client_id=$client_id&redirect_uri=$redirect_uri&scope=$scope&state=$state&code_challenge=$code_challenge&code_challenge_method=$code_challenge_method"

This link can be opened in browser but after login you will receive an message that Safari can not handle given link (that's because of this custom scheme)

Inside iOS everything will be fine

PCKE

But before there is one more catch with PCKE, here is pretty cool playground to generate code challange, make sure your results are comparable

Here is working example for nodejs:

const crypto = require('crypto')

// https://www.oauth.com/playground/authorization-code-with-pkce.html
const code_verifier = 'M9amGcIBsnKiJTiBEfN3YPf8aegQ8qQL2w97fh0PtzYrfKoa'
// base64url(sha256(code_verifier))
const expected_code_challenge = '25_Rx2lQGJHR1UCD1Rvx_eYnyBF-Zm2g3s3oI6fzL60'

const code_challenge = crypto.createHash('sha256').update(code_verifier).digest('base64url')
console.log(code_challenge == expected_code_challenge)

and here is how you can have base64url encoded sha256 in Swift:

import UIKit
import CryptoKit

// https://www.oauth.com/playground/authorization-code-with-pkce.html

// Define the expected code challenge
let expected = "25_Rx2lQGJHR1UCD1Rvx_eYnyBF-Zm2g3s3oI6fzL60"

// Define the code verifier
let codeVerifier = "M9amGcIBsnKiJTiBEfN3YPf8aegQ8qQL2w97fh0PtzYrfKoa"

let codeChallenge = Data(SHA256.hash(data: Data(codeVerifier.utf8))).base64EncodedString()
    .replacingOccurrences(of: "+", with: "-")
    .replacingOccurrences(of: "/", with: "_")
    .trimmingCharacters(in: ["="])

print(codeChallenge == expected)

iOS URL scheme

With this in place inside our empty app we may add an button that will open browser, aka:

import SwiftUI
import CryptoKit

struct ContentView: View {
    var url: URL {
        let codeVerifier = UUID().uuidString
        let codeChallenge = Data(SHA256.hash(data: Data(codeVerifier.utf8))).base64EncodedString()
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .trimmingCharacters(in: ["="])

        var url = URL(string: "https://accounts.google.com/o/oauth2/v2/auth")!
        url.append(queryItems: [
            URLQueryItem(name:"response_type",value: "code"),
            URLQueryItem(name:"client_id",value: "98400000000-700000000000000000000000000000va4.apps.googleusercontent.com"),
            URLQueryItem(name:"redirect_uri",value: "com.googleusercontent.apps.98400000000-700000000000000000000000000000va4:/callback"),
            URLQueryItem(name:"scope",value: "openid profile email https://www.googleapis.com/auth/drive.readonly"),
            URLQueryItem(name:"state",value: codeVerifier),
            URLQueryItem(name:"code_challenge",value: codeChallenge),
            URLQueryItem(name:"code_challenge_method",value: "S256")
        ])

        return url
    }
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
            Link("Login", destination: url)
        }
        .padding()
    }
}

@main
struct GoogleDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

It wont work out of the box - reason is the same - this custom schemas

demo1

To fix that add it to your app info like so:

demo2

Now, at least error message is gone, but still nothing works, we need to add url handler to our app like so:

@main
struct GoogleDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                // added url handler
                .onOpenURL(perform: { url in
                    print("OPEN:", url)
                })
        }
    }
}

And finally we get our callback working, url will be something like this:

com.googleusercontent.apps.98400000000-700000000000000000000000000000va4:/callback?state=C05ABF67&code=xxxxx&scope=email%20profile&authuser=0&prompt=consent

So it is our job now to extract code from it as well as state (note: technically it is a bad idea and we do not need to pass code verifier via state which makes it silly, but I left it as is so it is repeatable)

Token endpoint

Now, when we have code we gonna send an post request like this one:

code='...' # code we have extracted from callback url
code_verifier='...' # code verifier we have created when we generate auth link
client_id='98400000000-700000000000000000000000000000va4.apps.googleusercontent.com'
redirect_uri='com.googleusercontent.apps.98400000000-700000000000000000000000000000va4:/callback'
grant_type='authorization_code'

curl -X POST https://oauth2.googleapis.com/token -H 'Content-Type: application/x-www-form-urlencoded' -d "code=$code&code_verifier=$code_verifier&client_id=$client_id&redirect_uri=$redirect_uri&grant_type=$grant_type"

and in response we will receive access_token that we may use to talk to Google API

also there is an refresh_token that we should store somewhere in local storage (user default) to persist authentication beween app restarts

Refresh endpoint

Refresh endpoin is even easier and can be called like so:

grant_type='refresh_token'
refresh_token='...' # refresh token we have saved
code_verifier='...' # code verifier we have created when we generate auth link
client_id='98400000000-700000000000000000000000000000va4.apps.googleusercontent.com'

curl -X POST https://oauth2.googleapis.com/token -H 'Content-Type: application/x-www-form-urlencoded' -d "grant_type=$grant_type&refresh_token=$refresh_token&code_verifier=$code_verifier"

Refresh endpoint will give you new access token, refresh token itself stays untouched

Google Sheets API

As an example I am using Sheets API which is pretty simple and straight forward

aka here is how we can retrieve some cell values:

access_token='...' # our access token we have retrieved initially or after refresh
spreadsheet_id='...' # id of your spreadheet (take it from url)
sheet_name='Sheet1'
range='A2'
curl "https://sheets.googleapis.com/v4/spreadsheets/$spreadsheet_id/values/$sheet_name!$range" -H "Authorization: Bearer $access_token"

Recap

Here is a full code of what I have ended up with

import SwiftUI
import CryptoKit
import SafariServices

struct AnonymousView: View {
    @State private var isVisible = false
    var body: some View {
        VStack(spacing:20) {
            Image(systemName: "lock").imageScale(.large).foregroundStyle(.tint)
            Text("Anonymous")
            // Link("Login", destination: url) // will open real safari
            Button("Login") { isVisible.toggle() }.popover(isPresented: $isVisible, content: {
                SafariWebView(url: url)
            })
        }
    }

    private var url: URL {
        let codeVerifier = UUID().uuidString
        let codeChallenge = Data(SHA256.hash(data: Data(codeVerifier.utf8))).base64EncodedString()
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .trimmingCharacters(in: ["="])

        var url = URL(string: "https://accounts.google.com/o/oauth2/v2/auth")!
        url.append(queryItems: [
            URLQueryItem(name:"response_type",value: "code"),
            URLQueryItem(name:"client_id",value: "98400000000-700000000000000000000000000000va4.apps.googleusercontent.com"),
            URLQueryItem(name:"redirect_uri",value: "com.googleusercontent.apps.98400000000-700000000000000000000000000000va4:/callback"),
            URLQueryItem(name:"scope",value: "openid profile email https://www.googleapis.com/auth/drive.readonly"),
            URLQueryItem(name:"state",value: codeVerifier),
            URLQueryItem(name:"code_challenge",value: codeChallenge),
            URLQueryItem(name:"code_challenge_method",value: "S256")
        ])

        return url
    }
}

struct AuthenticatedView: View {
    @AppStorage("access_token") var accessToken: String?
    @AppStorage("refresh_token") var refreshToken: String?
    @State private var message = "Authenticated"
    var body: some View {
        VStack(spacing:20) {
            Image(systemName: "lock.open").imageScale(.large).foregroundStyle(.tint)
            Text(message)
            Button("Demo", action: demo)
            Button("Refresh", action: renew)
            Button("Logout", action: logout)
        }
    }

    func logout() {
        accessToken = nil
        refreshToken = nil
    }

    func demo() {
        var url = URL(string: "https://www.googleapis.com/drive/v3/files")!
        url.append(queryItems: [URLQueryItem(name:"q",value:"mimeType='application/vnd.google-apps.spreadsheet'")])
        var request = URLRequest(url: url)
        request.setValue("Bearer \(accessToken!)", forHTTPHeaderField: "Authorization")
        URLSession.shared.dataTask(with: request) { (data, response, error) in
            if let error = error {
                print("Error: \(error)")
                return
            }
            guard let data = data else {
                print("No data received")
                return
            }
            do {
                if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
                    if let files = json["files"] as? [Any] {
                        print("Got \(files.count) files")
                        message = "Got \(files.count) files"
                    }
                }
            } catch {
                print(error.localizedDescription)
            }
        }.resume()
    }

    func renew() {
        var parameters = URLComponents()
        parameters.queryItems = [
            URLQueryItem(name: "grant_type", value: "refresh_token"),
            URLQueryItem(name: "refresh_token", value: refreshToken),
            URLQueryItem(name: "client_id", value: "98400000000-700000000000000000000000000000va4.apps.googleusercontent.com")
        ]

        var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
        request.httpMethod = "POST"
        request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
        request.httpBody = parameters.query?.data(using: .utf8)

        URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                print("Error: \(error)")
                return
            }
            guard let data = data else {
                print("No data received")
                return
            }
            do {
                if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
                    if let accessToken = json["access_token"] as? String {
                        self.accessToken = accessToken
                        print("refreshed")
                        message = "Refreshed"
                    } else {
                        print("token missing in json")
                        self.accessToken = nil
                        self.refreshToken = nil
                    }
                }
            } catch {
                print(error.localizedDescription)
            }
        }.resume()
    }
}

struct ContentView: View {
    @AppStorage("access_token") var accessToken: String?
    @AppStorage("refresh_token") var refreshToken: String?

    var body: some View {
        if accessToken != nil && refreshToken != nil {
            AuthenticatedView()
        } else {
            AnonymousView()
        }
    }
}

@main
struct GoogleOpenIdConnectApp: App {
    @AppStorage("access_token") var accessToken: String?
    @AppStorage("refresh_token") var refreshToken: String?

    var body: some Scene {
        WindowGroup {
            ContentView()
                .onOpenURL(perform: { url in
                    guard let url = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return }
                    guard let codeVerifier = url.queryItems?.first(where: { $0.name == "state" })?.value else { return }
                    guard let code = url.queryItems?.first(where: { $0.name == "code" })?.value else { return }
                    // guard let scope = url.queryItems?.first(where: { $0.name == "scope" })?.value else { return }

                    var requestBodyComponents = URLComponents()
                    requestBodyComponents.queryItems = [
                        URLQueryItem(name: "code", value: code),
                        URLQueryItem(name: "code_verifier", value: codeVerifier),
                        URLQueryItem(name: "client_id", value: "98400000000-700000000000000000000000000000va4.apps.googleusercontent.com"),
                        URLQueryItem(name: "redirect_uri", value: "com.googleusercontent.apps.98400000000-700000000000000000000000000000va4:/callback"),
                        URLQueryItem(name: "grant_type", value: "authorization_code"),
                    ]

                    var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
                    request.httpMethod = "POST"
                    request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
                    request.httpBody = requestBodyComponents.query?.data(using: .utf8)

                    Task {
                        let (data, response) = try await URLSession.shared.data(for: request)
                        do {
                            if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
                                if let accessToken = json["access_token"] as? String, let refreshToken = json["refresh_token"] as? String {
                                    self.accessToken = accessToken
                                    self.refreshToken = refreshToken
                                } else {
                                    print("tokens missing in json")
                                    self.accessToken = nil
                                    self.refreshToken = nil
                                }
                            } else {
                                print("invlid json")
                                self.accessToken = nil
                                self.refreshToken = nil
                            }
                        } catch {
                            print(error.localizedDescription)
                        }
                    }
                })
        }
    }
}

struct SafariWebView: UIViewControllerRepresentable {
    let url: URL

    func makeUIViewController(context: Context) -> SFSafariViewController {
        return SFSafariViewController(url: url)
    }

    func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {

    }
}

The only thing changed is that we are using popover window with SFSafariViewController so our signin flow is more straight forward

Widgets and App Groups

To share tokens between app and widget you need to create an so called app group which can be done in target settings

Just create an group, something like groups.GoogleOpenIdConnectDemo and select it for both targets app and widget, and everywhere replace used of app storage to something like this:

@AppStorage("access_token",store: UserDefaults.init(suiteName: "group.GoogleOpenIdConnect")) var accessToken: String?

demo3