|
21 | 21 | { |
22 | 22 | "step": 3, |
23 | 23 | "description": "Open Chrome Custom Tabs, run Auth Code + PKCE, and receive tokens", |
24 | | - "code": " suspend fun signIn(): Intent? {\n _error.value = null\n _expired.value = false\n val issuerJavaUri = AuthConfig.issuer\n ?: run { _error.value = \"CIAM_ISSUER_HOST is missing — fill in local.properties\"; return null }\n if (AuthConfig.clientId.isEmpty()) {\n _error.value = \"CIAM_CLIENT_ID is missing — fill in local.properties\"; return null\n }\n val redirect = AuthConfig.redirectUri\n ?: run { _error.value = \"CIAM_REDIRECT_SCHEME is missing — fill in local.properties\"; return null }\n return try {\n val issuerAndroidUri = android.net.Uri.parse(issuerJavaUri.toString())\n val config = discoverConfiguration(issuerAndroidUri)\n val request = AuthorizationRequest.Builder(\n config,\n AuthConfig.clientId,\n ResponseTypeValues.CODE,\n redirect,\n )\n .setScopes(AuthConfig.scopes)\n .build()\n pendingAuthState = AuthState(config)\n authService.getAuthorizationRequestIntent(request)\n } catch (e: Throwable) {\n _error.value = e.localizedMessage ?: \"Authorization failed\"\n null\n }\n }", |
| 24 | + "code": " suspend fun signIn(): Intent? {\n _error.value = null\n _expired.value = false\n val issuerJavaUri = AuthConfig.issuer\n ?: run { _error.value = \"CIAM_ISSUER_HOST is missing — fill in local.properties\"; return null }\n if (AuthConfig.clientId.isEmpty()) {\n _error.value = \"CIAM_CLIENT_ID is missing — fill in local.properties\"; return null\n }\n val redirect = AuthConfig.redirectUri\n ?: run { _error.value = \"CIAM_REDIRECT_SCHEME is missing — fill in local.properties\"; return null }\n return try {\n val issuerAndroidUri = android.net.Uri.parse(issuerJavaUri.toString())\n val config = discoverConfiguration(issuerAndroidUri)\n val request = AuthorizationRequest.Builder(\n config,\n AuthConfig.clientId,\n ResponseTypeValues.CODE,\n redirect,\n )\n .setScopes(AuthConfig.scopes)\n .build()\n val intent = authService.getAuthorizationRequestIntent(request)\n pendingAuthState = AuthState(config)\n intent\n } catch (e: Exception) {\n _error.value = e.localizedMessage ?: \"Authorization failed\"\n null\n }\n }", |
25 | 25 | "file": "app/src/main/java/com/secureauth/quickstart/AuthViewModel.kt", |
26 | 26 | "lang": "kotlin", |
27 | | - "lines": "79-108" |
| 27 | + "lines": "79-109" |
28 | 28 | }, |
29 | 29 | { |
30 | 30 | "step": 4, |
31 | 31 | "description": "Revoke the access token at the IdP and clear local auth state", |
32 | | - "code": " suspend fun signOut() {\n _state.value?.lastTokenResponse?.accessToken?.let { token ->\n try { revokeToken(token) } catch (_: Throwable) { /* best-effort */ }\n }\n _state.value = null\n _expired.value = false\n _error.value = null\n expiryJob?.cancel()\n }", |
| 32 | + "code": " suspend fun signOut() {\n _state.value?.lastTokenResponse?.accessToken?.let { token ->\n try { revokeToken(token) } catch (_: Exception) { /* best-effort */ }\n }\n _state.value = null\n _expired.value = false\n _error.value = null\n expiryJob?.cancel()\n }", |
33 | 33 | "file": "app/src/main/java/com/secureauth/quickstart/AuthViewModel.kt", |
34 | 34 | "lang": "kotlin", |
35 | | - "lines": "136-146" |
| 35 | + "lines": "138-148" |
36 | 36 | } |
37 | 37 | ], |
38 | 38 | "framework": "android", |
|
81 | 81 | ], |
82 | 82 | "framework": "ios", |
83 | 83 | "lib": "AppAuth-iOS", |
84 | | - "lib_version": "1.7.6", |
| 84 | + "lib_version": "2.0.0", |
85 | 85 | "docs_url": "https://github.com/openid/AppAuth-iOS", |
86 | 86 | "install": "", |
87 | 87 | "repo_path": "samples/ios/login-pkce", |
|
186 | 186 | "run_command": "./gradlew :app:installDebug", |
187 | 187 | "callout": "Requires refresh_token grant type enabled in workspace [OAuth settings](workspace-oauth-general) and in the application's [Grant Types](workspace-applications-oauth). Also requires the offline_access scope." |
188 | 188 | }, |
| 189 | + "ios": { |
| 190 | + "steps": [ |
| 191 | + { |
| 192 | + "step": 1, |
| 193 | + "description": "Import AppAuth, Foundation, and Security (the Keychain backend)", |
| 194 | + "code": "import AppAuth\nimport Foundation\nimport Security", |
| 195 | + "file": "Quickstart/AuthClient.swift", |
| 196 | + "lang": "swift", |
| 197 | + "lines": "3-7" |
| 198 | + }, |
| 199 | + { |
| 200 | + "step": 2, |
| 201 | + "description": "Configure the OIDC client — `offline_access` (set in Config.xcconfig) enables refresh tokens", |
| 202 | + "code": "private func plist(_ key: String) -> String {\n Bundle.main.object(forInfoDictionaryKey: key) as? String ?? \"\"\n}\n\nstruct AuthConfig {\n static let issuer = AuthConfig.buildIssuerURL(host: plist(\"CIAM_ISSUER_HOST\"),\n path: plist(\"CIAM_ISSUER_PATH\"))\n static let clientId = plist(\"CIAM_CLIENT_ID\")\n static let redirectURI: URL? = {\n let scheme = plist(\"CIAM_REDIRECT_SCHEME\")\n guard !scheme.isEmpty else { return nil }\n return URL(string: \"\\(scheme)://oauthredirect\")\n }()\n static let scopes = plist(\"CIAM_SCOPES\").split(separator: \" \").map(String.init)\n\n static func buildIssuerURL(host: String, path: String) -> URL? {\n guard !host.isEmpty else { return nil }\n let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: \"/\"))\n let urlString = trimmedPath.isEmpty ? \"https://\\(host)\" : \"https://\\(host)/\\(trimmedPath)\"\n return URL(string: urlString)\n }\n}", |
| 203 | + "file": "Quickstart/AuthClient.swift", |
| 204 | + "lang": "swift", |
| 205 | + "lines": "10-33" |
| 206 | + }, |
| 207 | + { |
| 208 | + "step": 3, |
| 209 | + "description": "Trigger login with offline_access and capture the refresh token", |
| 210 | + "code": " func signIn(presenting: UIViewController) async {\n error = nil\n do {\n guard let issuer = AuthConfig.issuer else {\n throw AuthError.misconfigured(\"CIAM_ISSUER_HOST is missing — fill in Config.xcconfig\")\n }\n guard !AuthConfig.clientId.isEmpty else {\n throw AuthError.misconfigured(\"CIAM_CLIENT_ID is missing — fill in Config.xcconfig\")\n }\n guard let redirectURI = AuthConfig.redirectURI else {\n throw AuthError.misconfigured(\"CIAM_REDIRECT_SCHEME is missing — fill in Config.xcconfig\")\n }\n let config = try await discoverConfiguration(forIssuer: issuer)\n let request = OIDAuthorizationRequest(\n configuration: config,\n clientId: AuthConfig.clientId,\n scopes: AuthConfig.scopes,\n redirectURL: redirectURI,\n responseType: OIDResponseTypeCode,\n additionalParameters: nil\n )\n let authState = try await authStateByPresenting(request: request, presenting: presenting)\n let next = AuthClient.makeTokens(from: authState.lastTokenResponse, fallback: nil)\n tokens = next\n if let refresh = next?.refreshToken {\n persistRefreshToken(refresh)\n }\n scheduleRefreshTimer()\n } catch {\n self.error = error.localizedDescription\n }\n }", |
| 211 | + "file": "Quickstart/AuthClient.swift", |
| 212 | + "lang": "swift", |
| 213 | + "lines": "52-85" |
| 214 | + }, |
| 215 | + { |
| 216 | + "step": 4, |
| 217 | + "description": "Persist the refresh token in iOS Keychain (kSecClassGenericPassword, kSecAttrAccessibleWhenUnlocked)", |
| 218 | + "code": "struct RefreshTokenStore {\n let service: String\n\n init(service: String = \"com.secureauth.quickstart.ios.refresh\") {\n self.service = service\n }\n\n private var account: String { \"refreshToken\" }\n\n func save(_ token: String) throws {\n guard let data = token.data(using: .utf8) else {\n throw RefreshTokenStoreError.unexpectedData\n }\n // Try update first (in case an entry exists), fall back to add. Avoids the\n // \"duplicate item\" error from SecItemAdd when overwriting.\n let updateStatus = SecItemUpdate(matchQuery() as CFDictionary,\n [kSecValueData as String: data] as CFDictionary)\n if updateStatus == errSecSuccess { return }\n if updateStatus != errSecItemNotFound {\n throw RefreshTokenStoreError.osStatus(updateStatus)\n }\n var addQuery = matchQuery()\n addQuery[kSecValueData as String] = data\n addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked\n let addStatus = SecItemAdd(addQuery as CFDictionary, nil)\n if addStatus != errSecSuccess {\n throw RefreshTokenStoreError.osStatus(addStatus)\n }\n }\n\n func load() throws -> String? {\n var query = matchQuery()\n query[kSecReturnData as String] = true\n query[kSecMatchLimit as String] = kSecMatchLimitOne\n var result: AnyObject?\n let status = SecItemCopyMatching(query as CFDictionary, &result)\n if status == errSecItemNotFound { return nil }\n if status != errSecSuccess {\n throw RefreshTokenStoreError.osStatus(status)\n }\n guard let data = result as? Data, let token = String(data: data, encoding: .utf8) else {\n throw RefreshTokenStoreError.unexpectedData\n }\n return token\n }\n\n func clear() throws {\n let status = SecItemDelete(matchQuery() as CFDictionary)\n // errSecItemNotFound is fine — clear is idempotent.\n if status != errSecSuccess && status != errSecItemNotFound {\n throw RefreshTokenStoreError.osStatus(status)\n }\n }\n\n /// Common query attributes shared across save/load/clear.\n /// `kSecUseDataProtectionKeychain: true` opts into the modern data-protection\n /// keychain (iOS 13+), which doesn't require a Keychain Sharing entitlement and\n /// works correctly in unit-test bundles run with code signing disabled.\n private func matchQuery() -> [String: Any] {\n return [\n kSecClass as String: kSecClassGenericPassword,\n kSecAttrService as String: service,\n kSecAttrAccount as String: account,\n kSecUseDataProtectionKeychain as String: true,\n ]\n }\n}\n\nenum RefreshTokenStoreError: Error {\n case osStatus(OSStatus)\n case unexpectedData\n}", |
| 219 | + "file": "Quickstart/RefreshTokenStore.swift", |
| 220 | + "lang": "swift", |
| 221 | + "lines": "4-77" |
| 222 | + }, |
| 223 | + { |
| 224 | + "step": 5, |
| 225 | + "description": "Exchange the refresh token for a new access token; on failure clear local state and require re-login", |
| 226 | + "code": " func refreshTokens() async {\n error = nil\n guard let issuer = AuthConfig.issuer else {\n error = \"CIAM_ISSUER_HOST is missing — fill in Config.xcconfig\"\n return\n }\n guard !AuthConfig.clientId.isEmpty else {\n error = \"CIAM_CLIENT_ID is missing — fill in Config.xcconfig\"\n return\n }\n let storedRefresh: String?\n if let inMemory = tokens?.refreshToken, !inMemory.isEmpty {\n storedRefresh = inMemory\n } else {\n storedRefresh = (try? store.load()).flatMap { $0.isEmpty ? nil : $0 }\n }\n guard let stored = storedRefresh else {\n error = \"No refresh token available. Sign in first.\"\n return\n }\n do {\n let config = try await discoverConfiguration(forIssuer: issuer)\n let request = OIDTokenRequest(\n configuration: config,\n grantType: OIDGrantTypeRefreshToken,\n authorizationCode: nil,\n redirectURL: nil,\n clientID: AuthConfig.clientId,\n clientSecret: nil,\n scope: nil,\n refreshToken: stored,\n codeVerifier: nil,\n additionalParameters: nil\n )\n let response = try await performTokenRequest(request)\n let next = AuthClient.makeTokens(from: response, fallback: tokens)\n tokens = next\n if let newRefresh = next?.refreshToken, newRefresh != stored {\n persistRefreshToken(newRefresh)\n }\n scheduleRefreshTimer()\n } catch {\n // Refresh tokens can be revoked or expire — clear local state and force re-login.\n try? store.clear()\n tokens = nil\n refreshTimer?.cancel()\n self.error = \"Refresh failed (\\(error.localizedDescription)). Sign in again.\"\n }\n }", |
| 227 | + "file": "Quickstart/AuthClient.swift", |
| 228 | + "lang": "swift", |
| 229 | + "lines": "88-138" |
| 230 | + } |
| 231 | + ], |
| 232 | + "framework": "ios", |
| 233 | + "lib": "AppAuth-iOS", |
| 234 | + "lib_version": "2.0.0", |
| 235 | + "docs_url": "https://github.com/openid/AppAuth-iOS", |
| 236 | + "install": "", |
| 237 | + "repo_path": "samples/ios/token-refresh", |
| 238 | + "run_command": "open Quickstart.xcodeproj", |
| 239 | + "callout": "Requires refresh_token grant type enabled in workspace [OAuth settings](workspace-oauth-general) and in the application's [Grant Types](workspace-applications-oauth). Also requires the offline_access scope." |
| 240 | + }, |
189 | 241 | "react-native": { |
190 | 242 | "steps": [ |
191 | 243 | { |
|
0 commit comments