singpass / Android-Singpass-in-app-browser-login-demo

This repository demonstrates and details the implementation using Chrome Custom Tabs or external web browsers to initiate a Singpass login using Oauth PKCE flow
3 stars 2 forks source link

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.

Sequence Diagram

Sequence Diagram
*RP stands for Relying Party

​* - 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

Potential changes/enhancements for RP Backend

  1. Implement endpoint to serve code_challenge, code_challenge_method, state, nonce and other parameters needed for RP Mobile App to initiate the login flow.

  2. Implement endpoint in receive authorization code, state and other required parameters.
  3. Register your new redirect_uri for your OAuth client_id

Potential changes/enhancements for RP Mobile App

  1. Integrate AppAuth library to handle launching of authorization endpoint webpage in a Chrome Custom Tabs or external mobile web browser.

  2. Implement api call to RP Backend to request for code_challenge, code_challenge_method, state and nonce if required and other parameters.

  3. Implement api call to send authorization code, state and other needed parameters back to RP Backend.

Other Notes

Implementation Details

Required dependencies

AppAuth Android Library

implementation "net.openid:appauth:0.11.1"

Androidx Browser (Chrome Custom Tabs)

implementation "androidx.browser:browser:1.5.0"

Implementation

In the AndroidManifest.xml

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>


In the ViewModel

The below code snippets should be inside a ViewModel or any other component that survives an orientation change in an Android application.


Create the Oauth service configuration

  // 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)


Create the OAuth authorization request

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()


Create the OAuth authorization service


// 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()
}

In the UI Layer (Activity or Fragment)


Create an authActivityLauncher in your Activity or Fragment.

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
            )
        }
    }
}


Launch the authorization Intent created in the viewModel

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()

Demo Video/s

MyInfo Consent

MyInfo Mockpass Demo
(Chrome Custom Tab)
MyInfo Mockpass Demo
(External browser fallback)
Myinfo Mockpass flow video Myinfo Mockpass flow video

Singpass Login

Singpass Demo
(Chrome Custom Tab)
Singpass Demo
(External browser fallback)
Singpass flow video Singpass flow video

FAQ

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
Brave browser chrome custom tab Chrome browser chrome custom tab
Firefox Browser CCT Firefox Focus Browser CCT
Firefox browser chrome custom tab Firefox Focus browser chrome custom tab
Microsoft Edge Browser CCT Huawei Browser CCT
Microsoft Edge chrome custom tab Huawei browser chrome custom tab
Samsung Internet Browser CCT
Samsung Internet browser chrome custom tab


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
Opera Web browser DuckDuckGo browser

Known issues

Polling

Vote here to indicate if you would like a library that handles all these implementation