Mobile
Sign in with NIA — iOS
Add “Sign in with NIA” to a native iOS app with AppAuth-iOS, ASWebAuthenticationSession, and PKCE — no client secret required.
01Overview
This guide adds “Sign in with NIA” to a native iOS application using the standard OpenID Connect Authorization Code flow with PKCE. NIA is a conformant OIDC provider, so you use AppAuth-iOS — the standard OAuth/OIDC client for Apple platforms — with no NIA-specific SDK.
iOS apps run as a public client: there is no client secret embedded in the app bundle. ASWebAuthenticationSession presents the sign-in screen in a secure, isolated browser context, and AppAuth generates and verifies the PKCE challenge for you.
- Flow: Authorization Code + PKCE (S256), public client
- Tokens: RS256 access & ID tokens, opaque refresh token
- You write: auth trigger, redirect handling, Keychain persistence
02Requirements
- Xcode 14+, iOS 13+ deployment target, Swift 5
- A NIA account with access to the client dashboard
- A registered OAuth client (public,
client_idonly — no secret) - Your app's custom URL scheme registered as the redirect URI
- A device or simulator running iOS 13 or later
03Installation
Add AppAuth-iOS via Swift Package Manager (recommended) or CocoaPods.
File → Add Package Dependencies…
https://github.com/openid/AppAuth-iOS
# or add directly to Package.swift:
.package(url: "https://github.com/openid/AppAuth-iOS", from: "1.7.0")04Required OAuth library
Discover NIA's configuration once at launch (or lazily before first login). AppAuth fetches the discovery document and caches every endpoint — authorization, token, JWKS — from it.
import AppAuth
enum NiaAuth {
static let issuer = URL(string: "https://dev-api.niaauth.com")!
static func discoverConfiguration(
completion: @escaping (OIDServiceConfiguration?, Error?) -> Void
) {
OIDAuthorizationService.discoverConfiguration(forIssuer: issuer) { config, error in
DispatchQueue.main.async {
completion(config, error)
}
}
}
}OIDServiceConfiguration for the lifetime of the app session.05Environment Variables
Native apps ship no server-side secrets — a public client has no client_secret to protect. Keep the issuer, client ID, and redirect URI as build-time constants (or per-configuration .xcconfig values for Debug/Release).
enum NiaConfig {
static let issuer = "https://dev-api.niaauth.com"
static let clientId = "your-client-id"
static let redirectURI = URL(string: "com.example.app:/oauth2redirect")!
}client_secret in an iOS app — it can be extracted from the binary. Register the client as public in the NIA dashboard.06Register OAuth Client
In the NIA dashboard, create the client your iOS app will authenticate as:
- Open Dashboard → Clients → New client
- Client type: Native / Public (no client secret is issued)
- Allowed scopes:
openidemailprofileoffline_access - Add your redirect URI (next step) — the exact custom scheme
- Copy the
client_id— public clients have no secret to copy
07Redirect URI
Native apps use a custom URL scheme instead of an HTTPS redirect. NIA matches it exactly, so register precisely what the app declares in Info.plist.
com.example.app:/oauth2redirectHold the in-flight authorization session so the system can resume it when the app is reopened via the custom scheme:
import AppAuth
class AppDelegate: UIResponder, UIApplicationDelegate {
var currentAuthorizationFlow: OIDExternalUserAgentSession?
func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
if let flow = currentAuthorizationFlow,
flow.resumeExternalUserAgentFlow(with: url) {
currentAuthorizationFlow = nil
return true
}
return false
}
}Info.plist, the redirectURI passed to AppAuth, and the URI registered on the client must all be byte-identical.08Issuer
The issuer is the single URL AppAuth needs — its discovery document advertises all endpoints below.
Issuer: https://dev-api.niaauth.com
Discovery: https://dev-api.niaauth.com/.well-known/openid-configuration
Authorization: https://dev-api.niaauth.com/oauth/authorize
Token: https://dev-api.niaauth.com/oauth/token
UserInfo: https://dev-api.niaauth.com/oauth/userinfo
JWKS: https://dev-api.niaauth.com/oauth/jwks.json09Authorization Flow
Trigger sign-in from a view controller. OIDAuthState.authState(byPresenting:) presents an ASWebAuthenticationSession, generates the PKCE verifier/challenge (S256) automatically, and keeps the flow alive across the app-switch to Safari and back.
import AppAuth
import UIKit
func startLogin() {
NiaAuth.discoverConfiguration { config, error in
guard let config = config else {
print("Discovery failed: \(String(describing: error))")
return
}
let request = OIDAuthorizationRequest(
configuration: config,
clientId: NiaConfig.clientId,
clientSecret: nil,
scopes: [OIDScopeOpenID, OIDScopeEmail, OIDScopeProfile, "offline_access"],
redirectURL: NiaConfig.redirectURI,
responseType: OIDResponseTypeCode,
additionalParameters: nil
)
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return }
appDelegate.currentAuthorizationFlow = OIDAuthState.authState(
byPresenting: request,
presenting: self
) { authState, error in
if let authState = authState {
self.persist(authState)
} else {
print("Authorization failed: \(String(describing: error))")
}
}
}
}10Token Exchange
authState(byPresenting:) performs authorization and token exchange in one call: it opens the sign-in session, receives the code, attaches the PKCE verifier, and exchanges it for tokens against https://dev-api.niaauth.com/oauth/token — no client secret required for a public client.
appDelegate.currentAuthorizationFlow = OIDAuthState.authState(
byPresenting: request,
presenting: self
) { authState, error in
guard let authState = authState else {
print("Token exchange failed: \(String(describing: error))")
return
}
let accessToken = authState.lastTokenResponse?.accessToken
let idToken = authState.lastTokenResponse?.idToken
let refreshToken = authState.lastTokenResponse?.refreshToken
self.persist(authState)
}11JWKS Validation
NIA signs ID tokens with RS256. AppAuth validates the ID token's signature against NIA's JWKS (fetched via discovery), along with iss, aud, exp, and nonce, as part of authState(byPresenting:). You do not verify manually.
Persist the resulting OIDAuthState — it is the source of truth for both the validated identity and the token lifecycle.
import AppAuth
import Security
enum KeychainStorage {
private static let key = "nia_auth_state"
static func save(_ authState: OIDAuthState) {
guard let data = try? NSKeyedArchiver.archivedData(
withRootObject: authState,
requiringSecureCoding: true
) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query.merging([kSecValueData as String: data]) { $1 } as CFDictionary, nil)
}
}12Session Management
Archive OIDAuthState to the Keychain after every state change, and restore it on app launch so the user stays signed in between sessions.
import AppAuth
import Security
final class SessionManager: NSObject, OIDAuthStateChangeDelegate {
static let shared = SessionManager()
private(set) var authState: OIDAuthState?
func restore() {
authState = loadFromKeychain()
authState?.stateChangeDelegate = self
}
func setAuthState(_ state: OIDAuthState) {
authState = state
state.stateChangeDelegate = self
didChange(state)
}
var isSignedIn: Bool {
authState?.isAuthorized ?? false
}
func didChange(_ state: OIDAuthState) {
KeychainStorage.save(state)
}
}SessionManager.shared.restore() during app launch (e.g. application(_:didFinishLaunchingWithOptions:)) before checking isSignedIn.13Refresh Tokens
Since you requested offline_access, NIA returns a refresh token. Don't manage expiry yourself — call performAction before every API request and AppAuth silently refreshes when the access token is expired.
func callApi() {
SessionManager.shared.authState?.performAction { accessToken, idToken, error in
guard let accessToken = accessToken else {
print("Unable to refresh tokens: \(String(describing: error))")
return
}
var request = URLRequest(url: URL(string: "https://api.example.com/me")!)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
URLSession.shared.dataTask(with: request) { data, response, error in
// handle response
}.resume()
}
}performAction updates the in-memory OIDAuthState automatically, which triggers didChange and re-persists it to the Keychain — you don't handle rotation manually.14Logout
Build an OIDEndSessionRequest to NIA's end-session endpoint so the user is signed out of NIA too, then clear the Keychain.
import AppAuth
extension SessionManager {
func signOut(
from viewController: UIViewController,
config: OIDServiceConfiguration,
completion: @escaping (Error?) -> Void
) {
guard let idToken = authState?.lastTokenResponse?.idToken else {
completion(nil)
return
}
let request = OIDEndSessionRequest(
configuration: config,
idTokenHint: idToken,
postLogoutRedirectURL: NiaConfig.redirectURI,
additionalParameters: nil
)
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return }
appDelegate.currentAuthorizationFlow = OIDAuthorizationService.present(
request,
externalUserAgent: OIDExternalUserAgentIOS(presenting: viewController)!
) { _, error in
KeychainStorage.clear()
self.authState = nil
completion(error)
}
}
}15Deployment
Before submitting to the App Store:
- Register the production custom scheme (e.g. per-environment bundle ID) on the client
- Confirm the client is public — no secret is embedded or transmitted
- Ship the
CFBundleURLSchemesentry in the releaseInfo.plist - Store
OIDAuthStatein the Keychain only, neverUserDefaults - Set an appropriate Keychain accessibility level (e.g.
kSecAttrAccessibleAfterFirstUnlock)
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.example.app.oauth</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.example.app</string>
</array>
</dict>
</array>16Troubleshooting
- redirect_uri_mismatch — the custom scheme must match the registered one exactly; check
Info.plist,NiaConfig.redirectURI, and the dashboard entry. - Missing Info.plist URL scheme —
ASWebAuthenticationSessioncan't hand control back to the app ifCFBundleURLSchemesisn't declared. - currentAuthorizationFlow not retained — if the property holding the in-flight
OIDExternalUserAgentSessionis deallocated before the redirect returns, the flow silently drops. Keep it on a long-lived object likeAppDelegate. - invalid_client — a confidential client was registered by mistake. Native apps must use a public client with no secret.
- invalid_grant / PKCE failed — usually a stale or reused authorization code; restart the flow rather than retrying the same code.
- ID-token signature — usually device clock skew; ensure automatic date & time is enabled on the test device.