By Fabian Hauck (yes.com), Joseph Heenan (Authlete), Daniel Fett (yes.com)
OAuth flows on mobile devices can benefit a lot from native apps. With native apps, for example, it is possible to use already existing sessions and biometric authentication features. While apps improve the user experience, they also bring new security challenges to OAuth, especially for services like open banking. This document describes the challenges of redirections between native apps and web applications on Android and iOS and recommends solutions based on currently available features of the mobile operating systems and browsers. Our recommendations are more detailed than those from RFC8252 (OAuth 2.0 for Native Apps) and also address use cases with very high security requirements. A pull request for the AppAuth-Android project has been created.
When using OAuth to authorize the access to protected resources or OpenID Connect for authentication, it is crucial to secure the issuance process of the access token or ID token. Above all, it is important that the Authorization Request and the Authorization Response do not get hijacked by an adversary.
If, for example, an OAuth Authorization Response is hijacked and the client does not use PKCE, the adversary could inject the stolen authorization code into his own session to get access to the victim’s protected resources (Code Injection). PKCE can help to protect against Code Injection, but if the Authorization Request is hijacked as well, the attacker can modify it and use the resulting authorization code despite PKCE. This attack is similar to the one described here.
Even using Pushed Authorization Requests (PAR) or signing the Authorization Request will not mitigate all of the attacks that are possible if the OAuth redirection gets hijacked: For example, an attacker who can intercept the authorization request and response on a victim’s device could replace the whole authorization request (containing the PAR request URI or JAR signed request) with an authorization request generated using a client under his control. Then, after the victim authorized the access, the attacker could read the authorization response and use the authorization code on his own device, gaining access to the victim’s resources. Therefore, it is critical to properly secure the redirection to the OpenID Connect Identity Provider (IDP) (or OAuth authorization server) and back in terms of integrity and secrecy.
In a browser, it is generally secure to redirect the user using a URL, assuming that the URL is not under the attacker’s control, but on mobile operating systems, the situation is much more complicated.
Android, for example, permits arbitrary apps to claim that they handle a specific domain - even of other apps installed from the Google Play Store. Although it is possible to verify a domain-app association (Android App Link) this mechanism is not active if the app belonging to the domain is not installed on the device. In case the IDP app is not installed, the user would have to choose from a menu with which app he wants to open the URL.
As we can see in the picture, a malicious OpenID Provider app also claims to handle the authorization server’s domain. In this case, the user could choose the malicious app instead of the browser.
Another problem on Android is the redirection back from the IDP website loaded in the browser to the Relying Party (RP) app. If the RP app uses an Android App Link and the user was redirected to the Chrome browser, the user can be sent back to the app by simply being redirected to the Android App Link. This does not, however, work in most of the other browsers. To be browser independent, it is necessary to use a custom URL scheme. Arbitrary apps, however, can register themselves to handle the same custom scheme, making this technique susceptible to redirect hijacking.
On iOS the situation is different. There, an app can only claim to
handle an http://
or https://
scheme URL if it can verify an association
with the domain (Universal Links).
Custom schemes can be claimed, like on Android, by every app, but the OS will
not display a selection menu - it will instead arbitrarily pick one of the apps.
In the following, we will distinguish the following cases:
The web2web case is (more or less) classic OAuth/OIDC and therefore not considered in this document.
When talking about a class of security problems, it is always helpful to have a rough idea of the types of attackers that are considered relevant and those that are out of scope. Here, the attacker model could be outlined as follows:
We assume that attackers …
Specifically on Android, the following distinctions are important as well:
We assume that attackers …
Under the attacker model outlined above, what are the goals that a good solution should achieve?
When the user is redirected from the RP to the IDP, he should either go to
the legit app or to the default browser to ensure integrity and secrecy of
the authorization request. Ideally, an in-app browser that shares cookies
with the system’s default browser is opened (on Android, if supported, an
Android Custom Tab; on iOS ASWebAuthenticationSession
). There should never
be a menu where the user has to choose between multiple apps.
When the user is redirected from the IDP back to the RP, he should be sent back to the app/browser where he started the process.
On iOS, we can use Universal Links to redirect the user from one app to another.
The good thing here is that we can set a flag called .universalLinksOnly:
true
. This will only redirect the user if an app that has claimed this link is
installed and the link has been verified by the OS for the domain.
// if bank’s app is present & supports app2app, open it
UIApplication.shared.open(authorizationEndpointUrl, options: [.universalLinksOnly: true]) { (success) in
if !success {
// launching bank app failed: app does not support universal links or
// bank’s app is not installed – open an in app browser tab instead
<...continue as app did before app2app...>
}
}
(Source: Blog post by Joseph Heenan.)
In case the app is not installed, we can launch an ASWebAuthenticationSession that opens an in-app browser session powered by Safari. With this, it is possible to use existing session cookies from the Safari browser. To redirect back to the app, the app specifies a claimed https url before launching the browser. If the browser is redirected to the claimed https url the browser will exit and give the URL back to the app.
If the user normally uses a browser other than the system Safari, it is at best difficult to return them to that browser in the web2app flow, and to send them to that browser in the app2web flow.
iOS14 improves this situation by adding a system level preference where the user can select an alternative browser - however the alternative browser will not be able to share cookies/sessions with the system Safari, and ASWebAuthenticationSession
will always be handled by the system Safari. Unfortunately the rules for
alternate browsers do not currently seem to require them to implement Universal Links, nor to provide a similar user experience to Safari if they do support them.
iOS requires apps are signed using an extra permission that is manually granted by Apple before they can become the default browser, so it seems we can trust that the default browser is generally not malicious.
For the OAuth client (OIDC relying party), if flows starting in both app and mobile web are supported, it’s probably best to use different redirect uris depending on whether the flow starts in the app or in the mobile web browser, so there is a higher chance the flow ends up back where the user started. (By contrast, the OAuth server / OpenID Provider should generally not use different urls for its authorization endpoint for the web vs app flows, as there is no standard way to publish the alternative URL.)
For Android, we will first look at the challenges of the app2app/app2web case and the web2app case separately. Afterwards, we will provide a recommendation for a robust solution.
How could we approach the problem on Android?
val uri = Uri.parse("https://app2app.unsicher.ovh?request_uri=Hello_World")
val browserIntent = Intent(Intent.ACTION_VIEW, uri)
startActivity(browserIntent)
If the app of the domain owner is installed and supports Android App Links, the legit app will always be opened. If the domain owner app is not installed, the OS will display an app chooser dialog (as can be seen in the screenshot above). Since any app can claim to handle the domain name, the user could choose an app from an attacker. This is not a good solution.
val uri = Uri.parse("https://app2app.unsicher.ovh?request_uri=Hello_World")
val browserIntent = Intent.makeMainSelectorActivity(Intent.ACTION_MAIN, Intent.CATEGORY_APP_BROWSER)
browserIntent.data = uri
startActivity(browserIntent)
This code forces the OS to open the link inside a web browser and not in an app that just claims to open this domain name. If the user has multiple browsers installed, he still has to choose between them.
But is a browser automatically trustworthy? In the Google Play Store is it not clear (for us) how hard it is to publish an app that provides CATEGORY_APP_BROWSER, whereas on iOS, a signing permission manually granted by Apple is required for an app to be selectable as the default browser.
val uri = Uri.parse("https://app2app.unsicher.ovh?request_uri=Hello_World")
val builder = CustomTabsIntent.Builder()
val customTabsIntent = builder.build()
customTabsIntent.launchUrl(this, uri)
This code opens an Android Custom Tab. The problem here is that if the user does not have a default browser (unlikely), he has to choose between all installed browsers. Additionally, if a malicious app is installed the user has to choose between this app and the browser. It is a similar problem as in solution 1.
However a positive side is that if the user chooses a browser that does not support Custom Tabs, the browser application will launch.
val uri = Uri.parse("https://app2app.unsicher.ovh?request_uri=Hello_World")
val builder = CustomTabsIntent.Builder()
val customTabsIntent = builder.build()
val defaultBrowser = getDefaultBrowserPackageName()
customTabsIntent.intent.setPackage(defaultBrowser)
customTabsIntent.launchUrl(this, uri)
private fun getDefaultBrowserPackageName(): String {
/*
Source: https://stackoverflow.com/questions/23611548/how-to-find-default-browser-set-on-android-device
*/
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("http://"))
val resolveInfo = packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)
// This is the default browser's packageName
return resolveInfo!!.activityInfo.packageName
}
To prevent the user from having to choose between multiple apps, an intent
can be explicitly told which package it should use to execute the intent. In
this example an Android Custom Tab is initialized and the
getDefaultBrowserPackageName()
method finds out what the package name of
the user’s default browser is.
A problem with Chrome Custom Tabs is that if the user clicks on Open in
Chrome
and the OpenID Provider app is not installed but a malicious app is
installed, the user has to choose between Chrome and the malicious app.
Solutions 2 and 4 have the problem that they will open a web browser even if the app of the domain owner is installed on the device. To open the app if it is installed, it is necessary to first check whether the app is installed at all. The code for this can be seen below. The app must, of course, register the domain name as an Android App Link.
val packageName = "com.example.openidprovider"
if (isAppInstalled(packageName)) {
// Solution 1 -> will open the installed app because of the Android App Link
} else {
// Solution 4 -> will open the website in a Chrome Custom Tab with the user's default browser
}
private fun isAppInstalled(packageName: String): Boolean {
/*
Source: https://stackoverflow.com/questions/3922606/detect-an-application-is-installed-or-not
*/
try {
packageManager.getPackageInfo(
packageName,
PackageManager.GET_ACTIVITIES
)
return true
} catch (e: PackageManager.NameNotFoundException) {
return false
}
}
How do we get the package name? Two mechanisms are conceivable: We take the
issuer URL (or authorization server URL) and then either take the package name
from https://issuer/.well-known/assetlinks.json
or include it in the
OAuth/OpenID Connect server metadata (RFC
8414). For the latter, a new
metadata element would need to be standardized.
From Android 11, the app will need to request extra permissions to use the package manager APIs.
Does this solve all our problems? No!
Sometimes, app links are not the best solution. As an example, the yes® ecosystem consists of more than 1000 IDPs, that is, OpenID Connect issuers from independent domains. However, several hundred of these IDPs belong to the same banking group using the same mobile app. Therefore, this app is responsible for several hundred domains, each of which the app would need to register an Android App Link for. If only one of these registrations fails, none of them would be accepted by the OS. Not to speak of the huge performance problems, both on the client and server sides, when each app download would trigger the download of a document from several hundred domains.
To solve this, the Relying Party could add the package name to the
intent that opens the redirect URL. This would ensure that the
correct app opens without Android App Links. It is also a good
solution since we already need to have have the package name of the IDP
app to use the isAppInstalled
function.
val uri = Uri.parse("https://app2app.unsicher.ovh?request_uri=Hello_World")
val packageName = "com.example.openidprovider"
val redirectIntent = Intent(Intent.ACTION_VIEW, uri)
redirectIntent.setPackage(packageName)
startActivity(redirectIntent)
Are we done here? Not yet: This is not a sufficient replacement for Android AppLinks - for the app2app use case, the main difference is the application signature is not verified using this method.
Since Android is an open system, it is possible to install apps from other
sources than the Play Store. While it is guaranteed that the package name of
apps from the Play Store is unique, this does not apply to apps installed from
other sources. One possibility to overcome this security threat is to check the
signing certificate of the app we want to open. This can be done in the
isAppInstalled
method in the following way:
private fun isAppLegit(packageName: String): Boolean {
try {
// Try to query the signing certificates of the
// IDP app. If the IDP app is not installed this
// operation will throw an error.
val signingInfo: SigningInfo = packageManager.getPackageInfo(
packageName,
PackageManager.GET_SIGNING_CERTIFICATES
).signingInfo
val signatures: Array<Signature> = signingInfo.signingCertificateHistory
// calculate the hashes of the signing certificates
val signatureStrings = generateSignatureHashes(signatures)
// Compare the hashes with a predefined list of
// certificate hashes for the app.
return matchHashes(signatureStrings, IDP_SIGNATURE_HASHES)
} catch (e: PackageManager.NameNotFoundException) {
return false
}
}
The generateSignatureHashes
method is found in AppAuth-Android.
For this solution, as well as the package name, we also need
the hashes of the certificates that were used to sign the APK.
This can either be put into the OAuth/OpenID Discovery document or if the
IDP app uses Android App Links the hash can be found in the
/.well-known/assetlinks.json
file.
Now we have solved one direction. How do we get back from the OpenID Provider app to the calling activity in the Relying Party?
We can use the method startActivityForResult()
. This has two advantages:
First, the OpenID Provider app can get the package name of the calling app with
callingActivity?.packageName
and second, the IDP app can redirect to the
calling app with the setResult()
method.
Nevertheless, the IDP app should check if the redirect_uri from the AS, the package name, and the certificate fingerprint of the calling app matches. This should be done like this:
val foundPackageName: String? = callingActivity?.packageName
val (basePackageName, baseCertFingerprints) = getAssetLinksJsonFile(uri)
val foundCertFingerprints = getSigningCertificates(foundPackageName)
if (matchHashes(foundCertFingerprints, baseCertFingerprints)
&& foundPackageName == basePackageName) {
// Everything is fine call setResult()
} else {
// Something is wrong with the installed app.
// Redirect the user to the browser.
}
A small caveat remains: When an activity is launched with
startActivityForResult()
care is required when the iDP app launches further
activities, such as sub-activities to request login or 2-factor authentication.
What can we do to secure the web2app case?
<a href="https://app2app.unsicher.ovh/?code=foo_bar">To app via HTTPS</a>
If an app is installed that has the domain name registered via an Android App Link and the website was opened in the Chrome browser, the user will be redirected to the app without an app selection dialog. If no app with Android App Link is installed, the session will continue in Chrome browser.
If the website was opened in another browser, the user will be redirected to the website and not the app.
<a href="com.example.relyingparty://completed/?code=foo_bar">To app via custom scheme</a>
This solution has the advantage that every browser will open the app that supports the scheme. The disadvantage is that any app can register itself for the scheme. So an adversary could install an app for that scheme and then the user has to choose between apps.
Implementing a fallback if the app is not installed is possible but a little involved.
<!-- Source: https://developer.chrome.com/multidevice/android/intents -->
<a href="intent://relyingparty.intranet/?code=foo_bar#Intent;scheme=http;package=com.example.relyingparty;S.browser_fallback_url=https://app2app.unsicher.ovh/?code=foo_bar;end">To app via intent scheme</a>
The intent scheme
will use the package name to find an app to open the URL. This scheme is
essentially handled as an Android Intent by the system and works in every
browser. If no app with the package name is found, the user will be
redirected to the URL specified in the S.browser_fallback_url
parameter.
Since we can only specify the package name, a malicious app from another app store could hijack this kind of redirection. To prevent this, we would need a feature to specify the signing certificate hash of the target app. But this feature is not available.
The problem with the intent scheme and OAuth 2.0 is that the intent specification is in the fragment part of the URL. Since OAuth 2.0 just uses the redirect_uri and appends the parameters, it is not directly possible to use this type of URL. In fact, it is explicitly forbidden by RFC6749. We can circumvent this by redirecting the browser to a backend endpoint that redirects the browser to a URL with the intent scheme. This enables us to use an existing authorization server implementation without modifications.
Note: Since the Intent scheme is handled the same as a normal custom
scheme outside of Android, it is important that the endpoint that redirects
the user tries to determine whether the request came from a browser running
on Android that supports the intent://
scheme or another browser. This is
due to the fact that custom schemes introduce security risks to the OAuth
flow. If the endpoint detects any other device, it should display an error
message.
The selection of the browser has implications for the user experience and security:
Google Chrome:
Mozilla Firefox:
S.browser_fallback_url=[encoded_full_url]
SHOULD NOT be used in the
Intent scheme)Samsung Internet Browser:
DuckDuckGo and Puffin:
if the browser is redirected to a URL with a custom scheme that opens another app, the browser warns the user.
After seeing so many possible solutions for Android, what are the best techniques for Android? This section describes (what we think is) the best solution achievable for Android. This description is divided into the redirection from the RP app to the IDP, the IDP app to the RP, and the IDP website to the RP. This solution will not use Android App Links due to the problems noted above, but will instead set the package name of the apps explicitly to the Android Intent.
To redirect from the RP app to the IDP, the RP app has to check whether the IDP
app is installed. It does this by requesting the certificate with which the IDP
app was signed from the Android Package Manager. If the app is not installed,
the Package Manager will throw an exception. After this, the certificate hash
has to be compared to the hash that is found in the
/.well-known/assetlinks.json
file. If they are the same, the RP app can
redirect the user to the IDP app with an Android Intent that has the package
name of the IDP app set. The Intent can either be started with the method
startActivity()
or startActivityForResult()
.
If the IDP app is not installed, the RP app has to determine the user’s default browser and compare the certificate hash of this browser with a hardcoded hash. If this is successful, the RP app can open the browser with an Android Intent that has the package name of the default browser set.
Example Code:
// use methods from the AppAuth-Android project: https://github.com/openid/AppAuth-Android
import net.openid.appauth.browser.BrowserAllowList
import net.openid.appauth.browser.BrowserSelector
import net.openid.appauth.browser.VersionedBrowserMatcher
/**
* Main method to do the redirection
*/
fun secureRedirection(uri: Uri) {
// A full, generic implementation of this is more complex - the assetlinks.json may list
// multiple apps, only some of which the user has installed, and only some of which handle
// the path for the authorization endpoint
val (basePackageName, baseCertFingerprints) = getAssetLinksJsonFile(uri)
if (isAppLegit(basePackageName, baseCertFingerprints)) {
val redirectionIntent = Intent(Intent.ACTION_VIEW, uri)
redirectionIntent.setPackage(basePackageName)
startActivity(redirectionIntent)
} else {
redirectToWeb(uri)
}
}
fun redirectToWeb(uri: Uri) {
val builder = CustomTabsIntent.Builder()
val customTabsIntent = builder.build()
// find a suitable browser to open the URL
val browserDescriptor = BrowserSelector.select(
context,
BrowserAllowList(
VersionedBrowserMatcher.CHROME_CUSTOM_TAB,
VersionedBrowserMatcher.CHROME_BROWSER,
VersionedBrowserMatcher.FIREFOX_CUSTOM_TAB,
VersionedBrowserMatcher.FIREFOX_BROWSER,
VersionedBrowserMatcher.SAMSUNG_CUSTOM_TAB,
VersionedBrowserMatcher.SAMSUNG_BROWSER
)
)
if (browserDescriptor != null) {
customTabsIntent.intent.apply {
setPackage(browserDescriptor.packageName)
}
customTabsIntent.launchUrl(context, uri)
} else {
Toast.makeText(context, "Could not find a browser", Toast.LENGTH_SHORT).show()
}
}
fun isAppLegit(
packageName: String,
baseCertFingerprints: Set<String>
): Boolean {
val foundCertFingerprints = getSigningCertificates(packageName)
if (foundCertFingerprints != null) {
return matchHashes(baseCertFingerprints, foundCertFingerprints)
}
return false
}
fun matchHashes(certHashes0: Set<String>, certHashes1: Set<String>): Boolean {
if (certHashes0.containsAll(certHashes1)
&& certHashes0.size == certHashes1.size
) {
return true
}
return false
}
fun getSigningCertificates(packageName: String): Set<String>? {
try {
// Try to query the signing certificates of the
// IDP app. If the IDP app is not installed this
// operation will throw an error.
val signatures: Array<Signature>?
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
val signingInfo = packageManager.getPackageInfo(
packageName,
PackageManager.GET_SIGNING_CERTIFICATES
).signingInfo
signatures = signingInfo.signingCertificateHistory
} else {
signatures = packageManager.getPackageInfo(
packageName,
PackageManager.GET_SIGNATURES
).signatures
}
// calculate the hashes of the signing certificates
val foundCertFingerprints = generateSignatureHashes(signatures)
return foundCertFingerprints
} catch (e: PackageManager.NameNotFoundException) {
return null
}
}
Concerning this redirection, we have two cases that depend on whether the
method startActivityForResult()
is used to start the IDP app.
Case 1: getCallingActivity()
is null
startActivityForResult()
was not used from the RP app to the IDP app
In this case we can use the exact same method from above (secureRedirection(uri: Uri)
).
As noted for iOS, the RP native app may want to use a different redirect url to the web app if the user may start the flow from the system browser despite having the RP app installed.
Case 2: getCallingActivity()
is not null
This means startActivityForResult()
was used.
In this case the IDP app knows the package name of the calling app. With the
package name, the IDP app can get the signing certificate of the
calling app. This information can be compared with the values that are
stored in the /.well-known/assetlinks.json
file of the redirect_uri
domain. If all these values match and the redirect_uri is a registered one for that RP,
the IDP app can redirect the user back to the RP app with the
method setResult()
.
Example Code:
/**
* Method to redirect back to the RP app if the IDP app
* is started with 'startActivityForResult()'.
*/
fun secureRedirectionBackwards(uri: Uri) {
val foundPackageName: String? = callingActivity?.packageName
if (foundPackageName != null) {
val (basePackageName, baseCertFingerprints) = getAssetLinksJsonFile(uri)
val foundCertFingerprints = getSigningCertificates(foundPackageName)
if (foundCertFingerprints != null
&& matchHashes(foundCertFingerprints, baseCertFingerprints)
&& foundPackageName == basePackageName
) {
val redirectionIntent = Intent(Intent.ACTION_VIEW, uri)
setResult(0, redirectionIntent)
finish()
} else {
redirectToWeb(uri)
}
} else {
redirectToWeb(uri)
}
}
The redirect_uri should depend on whether the user starts a flow on the web or
in the Android app. This diagram shows the case where the user started in the RP
app but was redirected to the web because the IDP app was not installed. In this
case, the redirect_uri should point to an endpoint of the RP’s website that
takes the parameters from the Authorization Response and redirects the browser
to a URL that uses the intent://
scheme. In this intent://
scheme the RP can set
the package name of the RP app. If the flow is started from the genuine RP app,
the user will be returned to the same app. (The user or an attacker could have
installed a different app with same package name as the genuine app, it is up to
the RP to defend itself against rogue apps accessing its service.)
Example Code:
/**
* Example Java Spring rest controller endpoint to
* rewrite the URL.
*/
@GetMapping("/complete")
fun complete(@RequestParam code: String): RedirectView {
val redirection = RedirectView()
val codeEncoded = URLEncoder.encode(code, StandardCharsets.UTF_8)
redirection.url = "intent://relyingparty.intranet/complete?code=${codeEncoded}#Intent;scheme=http;package=com.example.relyingparty;S.browser_fallback_url=http://relyingpart.intranet/website;end"
return redirection
}
While redirecting from app2app and app2web can be secured really well on Android, it is difficult to secure the web2app redirection. There are two essential problems. First, Android App Links are only supported by the Chrome browser and second, it is not possible to set the certificate hash of the target app in the intent scheme. If either of these problems would be solved, we could safely redirect from the web to an app. So at the moment there are two options: Either the user is only allowed to use the Chrome browser (which is not possible if he starts the flow in another browser) or we have to accept the risk that the redirection could get hijacked by an app that was installed from an alternative app store with the same package name.
To solve this, we strongly recommend that alternate browsers are enhanced to support app links in the same way Chrome does.
Parts of this work were funded by the project Sichere digitale Identitäten in Nordrhein-Westfalen supported by the German Federal Ministry for Economic Affairs and Energy.
Update your browser to view this website correctly.