SSO Configuration for Corporate Mobile Application (SAML/OIDC)
SSO in corporate mobile app — not just "login with corporate account". Behind it stands IdP integration, right protocol choice, token handling per corporate security policies, and proper forced logout scenarios.
OIDC vs SAML: Protocol Choice
SAML 2.0 — XML-based protocol from 2005. Widely used in enterprise: ADFS, Okta, PingFederate. For mobile apps — inconvenient: SAML Assertions transmitted via HTTP POST (browser-based flow), requiring WebView or browser redirect. No native mobile SDK for SAML.
OpenID Connect (OIDC) — OAuth 2.0 overlay, uses JWT. Natively supported in mobile libraries. AppAuth — standard Authorization Code Flow with PKCE for iOS and Android.
If IdP supports both protocols (Okta, Azure AD, PingFederate do), for mobile choose OIDC. SAML needed only when IdP forces it: on-premise ADFS without modern update, or legacy corp system SAML-only.
OIDC Implementation via AppAuth
Authorization Code Flow with PKCE — mandatory standard for mobile (RFC 8252). No implicit flow — deprecated.
// Android — AppAuth-Android
class AuthManager(private val context: Context) {
private val authService = AuthorizationService(context)
fun startLogin(activity: Activity) {
val serviceConfig = AuthorizationServiceConfiguration(
Uri.parse("https://login.microsoftonline.com/$tenantId/oauth2/v2.0/authorize"),
Uri.parse("https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"),
null,
Uri.parse("https://login.microsoftonline.com/$tenantId/v2.0/.well-known/openid-configuration")
)
val request = AuthorizationRequest.Builder(
serviceConfig,
BuildConfig.CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse("com.company.app:/oauth2redirect")
)
.setScopes(
AuthorizationRequest.SCOPE_OPENID,
AuthorizationRequest.SCOPE_EMAIL,
AuthorizationRequest.SCOPE_PROFILE,
"offline_access"
)
.setPrompt("login") // Don't cache IdP session for corporate requirements
.build()
val intent = authService.getAuthorizationRequestIntent(request)
activity.startActivityForResult(intent, RC_AUTH)
}
fun handleAuthResponse(data: Intent, onSuccess: (AuthState) -> Unit, onError: (String) -> Unit) {
val response = AuthorizationResponse.fromIntent(data)
val exception = AuthorizationException.fromIntent(data)
if (response != null) {
authService.performTokenRequest(response.createTokenExchangeRequest()) { tokenResponse, ex ->
if (tokenResponse != null) {
val authState = AuthState(response, tokenResponse, ex)
saveAuthState(authState)
onSuccess(authState)
} else {
onError(ex?.message ?: "Token exchange failed")
}
}
} else {
onError(exception?.message ?: "Authorization failed")
}
}
}
On iOS similarly via AppAuth-iOS:
let configuration = OIDServiceConfiguration(
authorizationEndpoint: URL(string: "https://login.microsoftonline.com/\(tenantId)/oauth2/v2.0/authorize")!,
tokenEndpoint: URL(string: "https://login.microsoftonline.com/\(tenantId)/oauth2/v2.0/token")!
)
let request = OIDAuthorizationRequest(
configuration: configuration,
clientId: clientId,
scopes: [OIDScopeOpenID, OIDScopeEmail, OIDScopeProfile, "offline_access"],
redirectURL: URL(string: "com.company.app:/oauth2redirect")!,
responseType: OIDResponseTypeCode,
additionalParameters: nil
)
currentAuthorizationFlow = OIDAuthState.authState(
byPresenting: request,
presenting: self
) { authState, error in
if let authState = authState {
self.authStateManager.save(authState)
}
}
Token Storage and Automatic Refresh
AppAuth provides AuthState — manages tokens and automatically refreshes when access token expires:
fun makeApiRequest(url: String) {
authState.performActionWithFreshTokens(authService) { accessToken, _, exception ->
if (exception != null) {
// Refresh failed — need re-login
navigateToLogin()
return@performActionWithFreshTokens
}
// accessToken guaranteed fresh
apiClient.get(url, bearerToken = accessToken)
}
}
AuthState must be serialized and stored in EncryptedSharedPreferences (Android) / Keychain (iOS). Never SharedPreferences without encryption — corporate MDM policies detect this.
SAML via WebView
If IdP supports only SAML without OIDC wrapper — use custom WebView / SFSafariViewController for SAML assertion flow. Backend receives SAML Assertion, verifies, creates own JWT and returns via Deep Link.
Less secure approach (credentials pass through WebView), but sometimes only option. Flag this to customer as technical debt and recommend IdP upgrade.
Forced Logout and Session Revocation
Corporate requirement: on employee termination or account compromise — immediate access revocation. Mechanism: backend invalidates refresh token in IdP, next performActionWithFreshTokens fails, app redirects to login.
For Azure AD: revoke via Microsoft Graph POST /users/{id}/revokeSignInSessions. For Okta: POST /api/v1/users/{userId}/sessions.
End-session endpoint — standard OIDC logout mechanism. Important to call on logout, otherwise user can re-login without password via SSO cookie in browser:
fun logout() {
val endSessionRequest = EndSessionRequest.Builder(serviceConfig)
.setIdTokenHint(authState.idToken)
.setPostLogoutRedirectUri(Uri.parse("com.company.app:/logout"))
.build()
authService.performEndSessionRequest(endSessionRequest, pendingIntent)
clearLocalAuthState()
}
Common Issues
Clock skew. JWT validated by time — if device clock is 5+ minutes off, valid token rejected as expired. Handle with recommendation to sync time.
Redirect URI mismatch. Most common setup error. Redirect URI in code (com.company.app:/oauth2redirect) must match exactly what registered in IdP. Custom scheme vs Universal Link — different formats.
Multiple IdP in one organization. Large corps with M&A history often have multiple IdP. Need IdP discovery by email domain. Supported via OIDAuthorizationService.discoverConfiguration(forIssuer:).
SSO setup (OIDC + one IdP): 2–4 weeks. Multi-tenancy + multiple IdP + SAML fallback: 5–8 weeks. Cost estimated individually.







