Discourse Login on Android

Discourse Login on Android

A few days back, I was looking for a hobby side-project to work on. I wanted to develop an Android application (open source) and to collaborate with my peers on Google Udacity Android scholarship program. On the Udacity discussion forum, there were many threads filled with exciting ideas but they all were either too ambitious or required quite a bit of time.

Then, one day while checking into the forum an idea struck me! Why not make an app for the discussion forum itself! I knew the forum was powered by Discourse and quickly started looking up for ways in which in could create an app for it…

And voila! I found the official Discourse API. I quickly went through the API and found that for most of the calls the user needed to be authenticated (due to obivious reasons). But the only way to authenticate was to use an Admin generated API key. Obviously, I couldn’t generate the API keys myself and didn’t want to bother the forum moderators or admins. Besides that the Admin API key would grant complete read/write access to the forum to anyone who have it, not something a forum admin would want. So I thought to scrape the idea…

But then something struck me! I knew a user could log into the forum from a browser (very obvious) and the Discourse frontend (written in Ember) communicated to the backend (written in Ruby) using an API. I suspected if it was the same API? Turns out the API used by Discourse frontend is a superset of the public API and it uses the _t cookie as the Session Cookie.

And so I started looking for ways I could use this to create a login for the user of my app. Turns out it’s not that easy.

First I thought to use Chrome Custom Tabs but it turns out that you cannot pass cookies between an app and a chrome custom tab for security reasons

Then I tried using the WebView and succeeded to some extent. But the solution was very fragile as there is no well-defined /login page on Discourse and the user could easily navigate away from the WebView. I know I could’ve prevented the navigation but Discourse allows 3rd party login integration and then I would have to account for all possible domains a user could have navigated too! And besides these there are no autofill and password remembering features in WebView which means the user would have to login manually then.

And then I found what I was actually looking for! The Discourse User API key specification!! First created in Aug'16, this feature facilitates “application” access to Discourse instances without needing to involve moderators. The spec provides a flow similar to OAuth but with a few tweaks so that the Discourse instance doesn’t have to know about the app prior to authentication. The token generation flow is documented here

The problem with User API key spec is that it’s documentation is outdated and the canonical way is to follow the source of the Discourse Mobile App which is a companion app and provide features like Push notificaton, etc. (not browsing!) The app is written in React Native.

After a lot of trial-and-error I finally go it to work natively (in Java) on Android. And following is the Gist of what I did.

1 . Create a DiscourseAuthAgent class.

public class DiscourseAuthAgent {
  // Randomly generated or static client id for your app
  private String clientId;
  
  // Random string to verify that the 
  // reply indeed comes from Discourse server
  private String nonce;
  
  // RSA Public key
  private String publicKey;
  
  // RSA Private key
  private String privateKey;

  // API key we get from the server
  private String apiKey;
}

2 . To generate the RSA KeyPair

String[] generateKeyPair(){
  String[] result = new String[2];
  
  // generate KeyPair
  KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
  kpg.initialize(2048);
  KeyPair keyPair = kpg.generateKeyPair();

  // get and base64 encode public key
  byte[] publicKey  = keyPair.getPublic().getEncoded();
  String b64PublicKey = Base64.encodeToString(publicKey, Base64.DEFAULT);
  result[0] = b64PublicKey;

  // get and base63 encode private key
  byte[] privateKey = keyPair.getPrivate().getEncoded();
  String b64PrivateKey = Base64.encodeToString(privateKey, Base64.DEFAULT);
  b64PublicKey[1] = b64PrivateKey;

  return result;
}

3 . For the clientId and nonce you should generate a random string of length 32 and 16 respectively. I tried with other length options but failed so I guess it’s good to go with these values.

static String randomString(final int sizeOfRandomString) {
  // taken from https://stackoverflow.com/a/12116194/6611700
  String ALLOWED_CHARACTERS = 
    "0123456789qwertyuiopasdfghjklzxcvbnm";
  
  final Random random= new Random();
  final StringBuilder sb= new StringBuilder(sizeOfRandomString);
  
  for(int i=0;i<sizeOfRandomString;++i)
    sb.append(
      ALLOWED_CHARACTERS.charAt(
        random.nextInt(ALLOWED_CHARACTERS.length())
      )
    );
  return sb.toString();
}

4 . Now to generate a Uri,

/**
 * @param appName Your Application name that will be displayed to the user
 * @param scopes Permission scopes that you want (e.g. read, write)
 * @param redirectUri Uri to redirect to after authentication
 */
Uri generateAuthUri(String appName, String[] scopes, String redirectUri) {
  clientId = randomString(32 /* size */);
  nonce = randomString(16 /* size */);
  String[] rsaKeyPair = generateKeyPair();
  publicKey = rsaKeyPair[0];
  privateKey = rsaKeyPair[1];

  return Uri.parse(BASE_URL).buildUpon()
    .appendEncodedPath("user-api-key/new")
    .appendQueryParameter("scopes", TextUtils.join(",", scopes))
    .appendQueryParameter("client_id", clientId)
    .appendQueryParameter("nonce", nonce)
    .appendQueryParameter("auth_redirect", redirectUri)
    .appendQueryParameter("push_url", "https://api.discourse.org/api/publish_android")  // placeholder
    .appendQueryParameter("application_name", appName)
    .appendQueryParameter("public_key", getFormattedPublicKey(Base64.decode(rsaPublicKey, Base64.DEFAULT)))
    .build();
}

String getFormattedPublicKey(byte[] unformatted){
  String pkcs1pem = "-----BEGIN PUBLIC KEY-----\n";
  pkcs1pem += Base64.encodeToString(unformatted, Base64.DEFAULT);
  pkcs1pem += "-----END PUBLIC KEY-----";
  return pkcs1pem;
}

5 . Finally to validate the result of the authentication and to get the API key from the redirect, we need to decrypt the payload query parameter that was sent with the redirection. The payload is encrypted using our publicKey and can only be decrypted by our privateKey

boolean handleAuthIntent(Intent data){
  if(null == data.getData())
    return false;
  
  try {
    String payload = java.net.URLDecoder.decode(
      redirection.getData().getQueryParameter("payload").replace("+", "%2B"),
      "UTF-8"
    ).replace("%2B", "+");

    // get back the private key
    byte[] privateKeyBytes = Base64.decode(privateKey, Base64.DEFAULT);
    PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(privateKeyBytes);
    PrivateKey privateKey = KeyFactory.getInstance("RSA").generatePrivate(spec);

    // initialize the Cipher
    Cipher cipher = Cipher.getInstance("RSA/NONE/PKCS1Padding");
    cipher.init(Cipher.DECRYPT_MODE, privateKey);

    // decrypt the payload into json
    byte[] decryptedBytes = cipher.doFinal(Base64.decode(payload, Base64.DEFAULT));
    String decrypted = new String(decryptedBytes);

    // create json object, verify nonce and let the fun begin!!!
    JSONObject payloadJson = new JSONObject(decrypted);

    // compare nonce
    if(!nonce.equals(payloadJson.getString("nonce")))
      throw new IllegalAccessException("invalid nonce");

    apiKey = payloadJson.getString("key");

    // and we are all done...
    return true;
  } catch (Exception e) {
    // failed to decrypt!
    // app must die :(
    throw new RuntimeException(e);
  }
}

6 . And that’s it!! Now you just need to persist the clientId and apiKey(preferably in SharedPreferences) so that you can use them later to authenticate your requests.

Now to use the DiscourseAuthAgent class in your (say) LoginActivity, add this to your manifest :

<activity android:name=".LoginActivity" android:launchMode="singleTask">
  <!-- deep link redirection handler after authentication -->
  <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="discourse" android:host="auth_redirect" />
  </intent-filter>
</activity>

This will make sure that the same instance of the Activity gets the deep linking intent so that you can use the same DiscourseAuthAgent instance.

In the example LoginActivity, start authentication by getting Uri and launching the default browser or a Chrome Cutom Tab (preferred).

// on some button click
// redirect uri must be exactly the same, I tried with other but failed
Uri rediectTo = discourseAgent.generateAuthUri("My Amazing App", new String[]{ "read", "write" }, "discourse://auth_redirect");
CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder()
  .setToolbarColor(getResources().getColor(R.color.colorPrimary))
  .build();
customTabsIntent.launchUrl(this, redirectTo);

Then, when Chrome redirects the user back to your Activity:

@Override protected void onNewIntent(Intent intent) {
  super.onNewIntent(intent);
  if(null != intent.getData() && intent.getData().toString().startsWith(REDIRECT_URI)){
    // redirection intent
    Timber.d("reply: %s", intent.getData());
    if(discourseAgent.handleAuthIntent(intent)){
      // authentication completed!
      Timber.d("Session authenticated!");
    } else {
      Timber.d("Failed to authenticate session :(");
    }
  }
}

And well that’s it! Now that you have an api key you can make autheticated calls to the Discourse Public API using the key. Just send the key in the User-Api-Key HTTP header and your client id in the User-Api-Client-Id HTTP header.

Show Comments
Unless stated, contents of this site are licensed under CreativeCommons BY-SA 4.0
Source snippets licensed under MIT Riyaz Ali © 2021