Migrating away from WebView for Android Mobile app Singpass Logins
Usage of WebViews for web logins are not recommended due to security and usability reasons documented in RFC8252. Google has done the same for Google Sign-in in 2021.
This best current practice requires that only external user-agents like the browser are used for OAuth by native apps. It documents how native apps can implement authorization flows using the browser as the preferred external user-agent as well as the requirements for authorization servers to support such usage.
Quoted from RFC8252.
This repository has codes for a sample Android application implementing the recommended Proof Key for Code Exchange (PKCE) for Singpass logins. The application will demonstrate the Singpass login flow while utilizing Chrome Custom Tabs or external mobile web browser along with PKCE leveraging on the Android AppAuth library.
- 1a) Call RP Backend to obtain backend generate
code_challenge
,code_challenge_method
along withstate
andnonce
if required. # - 1b) RP Backend responds with the requested parameters. (
code_challenge
,code_challenge_method
,state
,nonce
) # - 2a) Open the Authorization endpoint in web browser via AppAuth providing query params of
redirect_uri
*,client_id
,scope
,code_challenge
,code_challenge_method
along withstate
andnonce
if required. There can be other query params provided if needed. e.g. (purpose_id
for myInfo use cases) - 2b) The
authorization code
will be delivered back to RP Mobile App. - 3a) RP Mobile App Upon reception of
authorization code
, proceed to relay the Authorization code back to RP Backend. # - 3b) RP Backend will use the
authorization code
along with the generatedcode_verifier
along withstate
andnonce
if required, and do client assertion to call the token endpoint to obtain ID/access tokens. - 3c) Token endpoint responds with the token payload to RP Backend.
- 3d) RP Backend process the token payload and does its required operations and responds to RP Mobile App with the appropriate session state tokens or data. #
​* - Take note that the redirect_uri
should be a non-https url that represents the app link of the RP Mobile App as configured in the AppAuth library as shown in the AndroidManifest.xml implementation.
​# - It is up to the RP to secure the connection between RP Mobile App and RP Backend
- Implement endpoint to serve
code_challenge
,code_challenge_method
,state
,nonce
and other parameters needed for RP Mobile App to initiate the login flow. - Implement endpoint in receive
authorization code
,state
and other required parameters. - Register your new
redirect_uri
for your OAuth client_id
- Integrate AppAuth library to handle launching of authorization endpoint webpage in a Chrome Custom Tabs or external mobile web browser.
- Implement api call to RP Backend to request for
code_challenge
,code_challenge_method
,state
andnonce
if required and other parameters. - Implement api call to send
authorization code
,state
and other needed parameters back to RP Backend.
- Do NOT use the query param
app_launch_url
when opening the authorization endpoint webpage for Android as it will break the flow with AppAuth library. - Strongly recommended to use either Android DeepLinks or iOS URL Schemes for your
redirect_uri
. This will prevent usability issues when Chrome Custom Tabs or an external web browser redirects back to the RP Mobile App. An example of such a URI is:sg.gov.singpass.app://ndisample.gov.sg/rp/sample
. - Although the sample mobile application code in this repository provides an example of how to receive the token endpoint response from the RP Backend, RPs will need to cater for their own processing of the token response instead.
- In the case where using use either Android DeepLinks or iOS URL Schemes as the
redirect_uri
is not possible, an additional query parameter,redirect_uri_https_type=app_claimed_https
should be added to the authorization endpoint when launching in the in-app browser. This applies only to direct Singpass logins, and not to Myinfo logins. An example of such a URI is:https://stg-id.singpass.gov.sg/auth?redirect_uri=https%3A%2F%2Fapp.singpass.gov.sg%2Frp%2Fsample&client_id=ikivDlY5OlOHQVKb8ZIKd4LSpr3nkKsK&response_type=code&state=9_fVucO3cHJIIjR50wr2ctFPYIJLMt_NV6rvLBNQxlztWSCCWbCYMkesXdBC93lX&nonce=7d0c9f09-1c1a-400e-b026-77cc7bc89cd0&scope=openid&code_challenge=ZnRSoTcoIncnebg0mCqNT-E5fbRNQ8zcYkly52-qWxw&code_challenge_method=S256&redirect_uri_https_type=app_claimed_https
. - Do contact us if you face any issues adding your
redirect_uri
.
AppAuth Android Library
implementation "net.openid:appauth:0.11.1"
Androidx Browser (Chrome Custom Tabs)
implementation "androidx.browser:browser:1.5.0"
Configure AppAuth RedirectUriReceiverActivity's IntentFilter
in AndroidManifest which is also the redirect_uri
.
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true"
tools:node="replace">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="sg.gov.singpass.app"
android:host="ndisample.gov.sg"
android:path="/rp/sample"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="sg.gov.singpass.app"
android:host="ndisample.gov.sg"
android:path="/rp/sample/"/>
</intent-filter>
<!-- This is for when you need to use https scheme redirect_uri -->
<!-- Once again we emphasize that we do NOT recommend using https scheme -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"
android:host="app.singpass.gov.sg"
android:path="/rp/sample"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"
android:host="app.singpass.gov.sg"
android:path="/rp/sample/"/>
</intent-filter>
</activity>
The below code snippets should be inside a ViewModel or any other component that survives an orientation change in an Android application.
// This is the json string that describes the current Oauth service
// This example is using the test environment for MyInfo Singpass login
// Todo: Modify these values for your use-case e.g. Singpass, MyInfo etc
val jsonConfig = "{" +
"\"issuer\":\"https://test.api.myinfo.gov.sg\"," +
"\"authorizationEndpoint\":\"https://test.api.myinfo.gov.sg/com/v4/authorize\"," +
"\"tokenEndpoint\":\"https://test.api.myinfo.gov.sg/com/v4/token\"" +
"}"
val serviceConfig = AuthorizationServiceConfiguration.fromJson(jsonConfig)
val authRequest = AuthorizationRequest.Builder(
serviceConfig, // from the above section
client_id, // RP client_id
ResponseTypeValues.CODE, // code
Uri.parse(refirect_uri) // redirect_uri
).apply {
val additionalParams = mutableMapOf<String, String>()
// MyInfo Singpass login does not need nonce and state
// It needs purpose_id and has different scope values
if (isMyinfo) {
setScope(app.getString(R.string.myinfo_scope))
additionalParams.put("purpose_id", "demonstration")
setNonce(null)
setState(null)
} else {
setScope("openid")
setState(state) // state generated from RP Backend
setNonce(nonce) // nonce generated from RP Backend
}
// code_challenge and code_challenge_method generated from RP Backend
// Set code_challenge for code_verifier as AppAuth library
// does NOT natively support externally generated code_verifier
// Set code_challenge as code_verifier as a hack
// as we are not calling token endpoint from the mobile app
setCodeVerifier(code_challenge, code_challenge, code_challenge_method)
if (additionalParams.isNotEmpty()) {
setAdditionalParameters(additionalParams)
}
}.build()
// This config can be configured for appAuth to deny usage of certain web browsers.
val appAuthConfig = AppAuthConfiguration.Builder()
.setBrowserMatcher(
BrowserDenyList(
// As of 26th May 2023 we are seeing a bug on the Microsoft Edge browser affecting app linking
// where fallback url will be open mistakenly when launching Singpass app on QR code click
VersionedBrowserMatcher(
"com.microsoft.emmx", // package name
setOf("Ivy-Rk6ztai_IudfbyUrSHugzRqAtHWslFvHT0PTvLMsEKLUIgv7ZZbVxygWy_M5mOPpfjZrd3vOx3t-cA6fVQ=="), // SHA512 hash of the signing certificate
true, // use Chrome Custom Tabs
VersionRange.ANY_VERSION // can configure to deny specific versions or version ranges
),
// As of 9th June 2023 we are seeing a bug on the Samsung Internet Browser affecting app linking
// where customs tabs from Samsung Internet browsers will close itself when launching Singpass app
// after clicking on QR code
VersionedBrowserMatcher(
Browsers.SBrowser.PACKAGE_NAME,
Browsers.SBrowser.SIGNATURE_SET,
true,
VersionRange.ANY_VERSION
)
)
).build()
val authService = AuthorizationService(applicationContext, appAuthConfig)
// or below if no appAuthConfig needed
val authService = AuthorizationService(applicationContext)
Create the Intent to launch the Authorization Endpoint in a Chrome Custom Tab or external web browser
// Todo: Modify to make the custom tabs fit your application theme for light mode
private val customTabColorSchemeParams = CustomTabColorSchemeParams.Builder().apply {
val toolbarColor = ContextCompat.getColor(app, R.color.primary)
setToolbarColor(toolbarColor)
setSecondaryToolbarColor(toolbarColor)
}.build()
// Todo: Modify to make the custom tabs fit your application theme for dark mode
private val darkCustomTabColorSchemeParams = CustomTabColorSchemeParams.Builder().apply {
val toolbarColor = ContextCompat.getColor(app, R.color.grey60)
setToolbarColor(toolbarColor)
setSecondaryToolbarColor(toolbarColor)
}.build()
// Create the custom tabs intent with CustomTabsIntent.Builder
// Modify how you want the custom tabs to look using the androidx.browser api
// This builder will also function to warm up the custom tabs in the background for faster custom tabs launching
val customTabsIntent = authService.customTabManager.createTabBuilder(authRequest.toUri()).apply {
setColorSchemeParams(CustomTabsIntent.COLOR_SCHEME_DARK, darkCustomTabColorSchemeParams)
setColorSchemeParams(CustomTabsIntent.COLOR_SCHEME_LIGHT, customTabColorSchemeParams)
setShowTitle(true)
setStartAnimations(app, android.R.anim.slide_in_left, android.R.anim.fade_out)
setExitAnimations(app, android.R.anim.fade_in, android.R.anim.slide_out_right)
}.build()
try {
authIntent = authService.getAuthorizationRequestIntent(authRequest, customTabsIntent)
} catch (e: ActivityNotFoundException) {
// Todo: This toast here is just to indicate the error, please show your own error UI
Toast.makeText(app, "No suitable web browser found!", Toast.LENGTH_SHORT).show()
}
The authActivityLauncher
will listen for the authorization code or any errors returned from the Chrome Custom Tabs or external web browser
val authActivityLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val data = it.data
if (data != null) {
val resp = AuthorizationResponse.fromIntent(data)
val ex = AuthorizationException.fromIntent(data)
if (ex != null) {
// Todo: This toast here is just to indicate the error, please show your own error UI
Toast.makeText(app, "Error occurred: ${ex.errorDescription}", Toast.LENGTH_SHORT).show()
return@registerForActivityResult
}
if (resp != null) {
// Todo: obtain the authorization code and state and send to RP Backend
viewModel.sendAuthCodeToBackend(
code = resp.authorizationCode ?: "",
state = resp.state
)
}
}
}
viewModel.authIntent?.run {
authActivityLauncher.launch(this)
} ?:
// Todo: This toast here is just to indicate the error, please show your own error UI
Toast.makeText(app, "Error occurred: Intent is null!", Toast.LENGTH_SHORT).show()
MyInfo Mockpass Demo (Chrome Custom Tab) |
MyInfo Mockpass Demo (External browser fallback) |
---|---|
![]() |
![]() |
Singpass Demo (Chrome Custom Tab) |
Singpass Demo (External browser fallback) |
---|---|
![]() |
![]() |
- How do i know if I am using Chrome Custom Tabs (CCT), external web browser or WebView?
You can tell if the Singpass login page is being open in Chrome Custom Tabs by looking at the dropdown menu. It should indicate that the Chrome Custom Tabs is being powered or run by an implemented web browser. And there usually is an option to open the webpage in the indicated web browser. Some of the web browsers that implement the Chrome Custom Tabs feature is shown below.
Brave Browser CCT | Chrome Browser CCT |
---|---|
![]() |
![]() |
Firefox Browser CCT | Firefox Focus Browser CCT |
---|---|
![]() |
![]() |
Microsoft Edge Browser CCT | Huawei Browser CCT |
---|---|
![]() |
![]() |
Samsung Internet Browser CCT |
---|
![]() |
You can tell if the Singpass login page is opened in a external web browser by looking for the editable address bar. Below are 2 examples.
Opera Web browser | DuckDuckGo Browser |
---|---|
![]() |
![]() |
- As of 26th May 2023 we are seeing a bug on the
Microsoft Edge v113.0.1774.63
affecting app linking, where fallback url will be open mistakenly when launching Singpass app on QR code click. Please refer to this to see how restrict specific browsers usage. Demo of the aforementioned behavior below, as compare to the expected behavior when using Chrome
- As of 9th June 2023 we are seeing a bug on the
Samsung Internet Browser v21.0.0.41
affecting app linking where customs tabs from Samsung Internet browser will close itself when launching Singpass app after clicking on QR code. Please refer to this to see how restrict specific browsers usage. Demo of the aforementioned behavior below, as compare to the expected behavior when using Chrome
Vote here to indicate if you would like a library that handles all these implementation