Base IdP

Base IdP

SDKs

Swift / SwiftUI

The BaseIdP Swift package — native PKCE login for SwiftUI apps on iOS, iPadOS, macOS, Mac Catalyst, and visionOS, with a Keychain-backed session and a server-handoff helper.

The Swift SDK is the Apple half of Base IdP. It opens the system browser with ASWebAuthenticationSession, runs PKCE, exchanges the code for tokens, fetches the verified principal, and stores the session in the Keychain so a returning user is already signed in on launch. It is a public client — no secret ever ships in the app binary.

The whole integration is one value (clientId) and a few lines of SwiftUI. If you have used the Flutter SDK, this is the same shape in Swift.

Zero dependencies

The package uses only first-party frameworks — AuthenticationServices, CryptoKit, Security, and SwiftUI. There is nothing else to vendor.

Install

The package lives at github.com/squareexp/base-idp-swift. Add it with Swift Package Manager.

In Xcode

  1. File → Add Package Dependencies…
  2. Paste the repo URL: https://github.com/squareexp/base-idp-swift
  3. Set the dependency rule to Up to Next Major from 1.0.0.
  4. Add the BaseIdP library product to your app target.

In Package.swift

Package.swift
dependencies: [
    .package(url: "https://github.com/squareexp/base-idp-swift.git", from: "1.0.0"),
],
targets: [
    .target(
        name: "MyApp",
        dependencies: [
            .product(name: "BaseIdP", package: "base-idp-swift"),
        ]
    ),
]

Minimum deployment target

BaseIdP requires iOS 15, macOS 12, Mac Catalyst 15, or visionOS 1. If your app targets something older, Xcode reports "…was compiled for a newer deployment target." Raise your target or pin an older SDK release.

Get set up in four steps

Follow these in order. Most "it builds but login never returns" problems come from skipping step 2.

Add your client id

The client id is the one value you type. It is public — it is fine to commit. Put it in your app target's Info.plist:

Info.plist
<key>BASE_IDP_CLIENT_ID</key>
<string>sq_live_yourapp</string>

You will read it with BaseIdPConfig.fromInfoPlist(), or pass it directly to BaseIdPAuth(clientId:).

Never add a client secret

Do not add BASE_IDP_CLIENT_SECRET to an app. A secret in a shipped binary is a leaked secret. Native apps are public clients and use PKCE. If your client is registered as confidential, sign-in fails immediately with confidential_client_in_mobile — see Troubleshooting.

Register your callback URL scheme

Your app needs a custom URL scheme that matches the redirect URI registered for the client (for example myapp://auth/callback → scheme myapp). Add it under your target's Info → URL Types, or in Info.plist:

Info.plist
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLName</key>
    <string>com.example.myapp.callback</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>myapp</string>
    </array>
  </dict>
</array>

The scheme (myapp, without ://) must match the redirect URI registered in your Base IdP app exactly. This is the single most common source of the "browser opens, never comes back" bug.

You usually do not pass a redirect URI in code

The SDK auto-selects the registered custom-scheme callback from your client config. Pass one explicitly only if the client registers more than one and you want a specific match.

Create the auth manager

AppAuth.swift
import BaseIdP

@MainActor
let auth = BaseIdPAuth(clientId: "sq_live_yourapp")
// or, reading Info.plist:
// let auth = try BaseIdPAuth(config: BaseIdPConfig.fromInfoPlist())

BaseIdPAuth is an ObservableObject. Hold it with @StateObject at the root of your app.

Add a sign-in button

ContentView.swift
import SwiftUI
import BaseIdP

struct ContentView: View {
    @StateObject private var auth = BaseIdPAuth(clientId: "sq_live_yourapp")

    var body: some View {
        switch auth.state {
        case .signedIn:
            if let user = auth.principal {
                Text("Signed in as \(user.name ?? user.email ?? user.id)")
            }
            Button("Sign out") { auth.signOut() }

        case .authenticating:
            ProgressView()

        case .signedOut, .failed:
            SignInWithBaseIdPButton(auth: auth)
            if case .failed(let error) = auth.state {
                Text(error.message).foregroundStyle(.red)
            }
        }
    }
}

That is a complete, working login. BaseIdPAuth restores any saved session from the Keychain in its initializer, so a returning user renders as .signedIn on the first frame.

Driving it yourself

If you would rather not use the button, call signIn() directly. It is async and throws BaseIdPError.

do {
    let session = try await auth.signIn()
    print(session.principal.email ?? "no email")
} catch let error as BaseIdPError {
    print(error.code, error.message)
}

signIn():

  1. Resolves the client config from Base IdP (cached after the first call).
  2. Rejects confidential clients — a native app must be public.
  3. Picks the redirect URI and derives the callback scheme.
  4. Generates a PKCE verifier/challenge and a random state.
  5. Presents ASWebAuthenticationSession and awaits the redirect.
  6. Validates state and the redirect URI, exchanges the code, fetches /v1/me.
  7. Persists the session and publishes state = .signedIn.

The session and the server handoff

If your SwiftUI app talks to your own backend, send the session to your exchange endpoint after login. serverHandoff() returns the canonical shape.

let session = try await auth.signIn()

var request = URLRequest(url: URL(string: "https://api.example.com/auth/idp-exchange")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: session.serverHandoff())

let (data, _) = try await URLSession.shared.data(for: request)

The payload:

{
  "access_token": "v4.public.eyJ...",
  "refresh_token": "sqr_rt_v1_...",
  "token_type": "PASETO",
  "expires_in": 3600,
  "principal": { "gid": "...", "sub": "...", "email": "...", "role": "..." }
}

Your backend verifies the PASETO signature (offline against the published Ed25519 key, or by calling /v1/me), upserts a user, and issues its own session. Any verify-only service works — see Frameworks.

Keeping the access token fresh

validAccessToken() refreshes only when the current token is expired (or within leeway of expiring), then returns a usable token.

let token = try await auth.validAccessToken()
var request = URLRequest(url: myAPIURL)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

Token persistence

By default the session is stored in the Keychain (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly), scoped to your client id. Swap the store when you need different behaviour:

// Require sign-in every launch (nothing persisted):
BaseIdPAuth(clientId: "sq_live_yourapp", store: InMemorySessionStore())

Pass prefersEphemeralSession: true to force a fresh login with no shared Safari cookies (no silent SSO) — the right default for account switchers.

Sign out

auth.signOut()

This clears the local Keychain session and resets state to .signedOut. It does not revoke the token on the server; do that from your backend if you need server-side revocation.

Errors

Every failure is a BaseIdPError with a stable code shared across all Base IdP SDKs. Switch on code, show message to developers (never to end users).

codeMeaning
login_cancelledThe user dismissed the auth browser. Not really an error — show nothing.
confidential_client_in_mobileThe client is registered confidential. Use a public client, or exchange on a backend.
no_redirect_uriNo custom-scheme callback is registered for the client.
invalid_redirect_uriThe callback URL did not match the registered redirect URI.
invalid_stateThe callback state did not match the login request (possible tampering).
config_discovery_failedPOST /v1/client-config rejected the client id.
token_exchange_failedPOST /oauth2/token rejected the code exchange.
principal_fetch_failedGET /v1/me rejected the access token.
web_auth_start_failedThe system could not start the auth session (usually a missing presentation anchor).
do {
    _ = try await auth.signIn()
} catch let error as BaseIdPError {
    switch error.code {
    case "login_cancelled":
        break // user backed out — no message
    default:
        showAlert(error.message)
    }
}

Troubleshooting

The install and first-run problems, in the order people hit them.

No such module 'BaseIdP'

The package resolved but the product is not on your target. Select your app target → General → Frameworks, Libraries, and Embedded Content and confirm BaseIdP is listed. If not, add it. Then File → Packages → Reset Package Caches and rebuild.

Package … was compiled for a newer deployment target

Your app's minimum deployment target is below the SDK's (iOS 15 / macOS 12). Raise it in General → Minimum Deployments, or pin an older SDK release.

The browser opens, but never comes back

This is almost always a scheme mismatch. Check all three agree, exactly:

  1. The redirect URI registered in your Base IdP app, e.g. myapp://auth/callback.
  2. The URL Types scheme in your target (myapp, no ://).
  3. What the SDK derives — it uses the scheme from the resolved redirect URI.

If they disagree, iOS has no route back to your app and the session hangs until the user cancels (which surfaces as login_cancelled).

confidential_client_in_mobile

Your client is registered as confidential (it has a secret). A native app can't keep a secret. Either register a public client for the app, or keep the confidential client and run the code exchange on a backend, forwarding only the authorization code from the device.

config_discovery_failed

The client id is wrong, inactive, or from the wrong environment. Confirm the id in the console and that you are pointing at the right issuer. For local development against a non-production Base IdP, set BASE_IDP_ISSUER in Info.plist (production is the default and needs no override).

web_auth_start_failed or a crash on presentationAnchor

The SDK looks for your app's active foreground window to anchor the browser. This can fail very early in launch or in unusual scene setups. Trigger sign-in from a view that is actually on screen (e.g. from a button tap), not from init or onAppear of a view that has not been presented yet.

Login works in Debug but not in a release/TestFlight build

Confirm the URL Types and bundle id are configured for the release configuration, not only Debug, and that the redirect URI registered in Base IdP matches the production scheme. Entitlements and bundle id must line up in the archived build.

The user is signed in again with the same account every time

ASWebAuthenticationSession shares cookies with Safari by default, which gives you silent SSO. If you want an account picker instead, create the manager with prefersEphemeralSession: true.

Keychain errors (errSecMissingEntitlement) on device

If you use App Groups or share the Keychain across an extension, give the store a matching access group by supplying your own KeychainSessionStore service. Plain single-app usage needs no entitlement.

Where to go next

On this page