Search code examples
androidfirebasewear-osandroid-wear-data-apiandroid-wear-2.0

Best practice for firebase authorization on Wear OS


I am implementing a firebase realtime database on Wear OS for an accompanying app connected to an Android device and I was wondering what are the best practices for authenticating the user on a wear watch. It is not very convenient to enter a email and password on small watch screens. Is it possible to pass a firebase authorization token through the wear os data layer and if so, how would you use the token from the Android device to authenticate the user on the wear watch?

Thank you, Donny


Solution

  • The documentation covers the different authentication approaches you could use.

    Ultimately, you will need at least a web-based method to authenticate the watch as you can't guarantee that the user will have your companion app installed or that the watch is not connected to an iOS device.

    You have two approaches available to you (that I can think of):

    Option 1: Short-lived token exchange

    In this method, you perform the following steps:

    1. Prompt the user to open a Sign In webpage or open the companion app (or send a RemoteIntent to open it for them)
    2. Once authenticated, call a Cloud Function that creates an authentication code (about 5-6 alphanumeric characters long) and store it securely in your database of choice with an expiry of 1 to 2 minutes.
    3. Get the user to input the code directly on their watch (or send it to the watch using the data layer).
    4. Send the code to another Cloud Function to exchange it for a Firebase ID token.
    const functions = require('firebase-functions');
    
    const sha256 = (s) => require('crypto').createHash('sha256').update(s).digest('base64');
    
    const lazyFirebaseAdmin = () => {
      const admin = require('firebase-admin');
      try {
        admin.app();
      } catch {
        admin.initializeApp();
      }
      return admin;
    }
    
    const createUserAuthCode = async (uid) => {
      const chars = "0123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"; // omitted O, I, l
      let code = "", charsLen = chars.length;
      for (let i=0; i<6; i++)
        code += chars[Math.floor(Math.random() * charsLen)];
    
      const encoded = sha256(code);
    
      await lazyFirebaseAdmin()
        .firestore()
        .collection('_server/auth/userCodes')
        .doc(encoded)
        .create({
          created: admin.firestore.FieldValue.serverTimestamp(),
          uid
        });
    
      return code;
    }
    
    const validateUserAuthCode = async (code) => {
      const encoded = sha256(code);
    
      const codeRef = lazyFirebaseAdmin()
        .firestore()
        .collection('_server/auth/userCodes')
        .doc(encoded);
    
      const snapshot = await codeRef.get();
    
      if (!snapshot.exists)
        return null; // not found
    
      const { uid, created } = snapshot.data();
    
      await codeRef.delete();
    
      if (created.toMillis() < Date.now() - (2 * 60 * 1000)) {
        return null; // too old
      }
    
      return uid || null;
    }
    
    const getDeviceCode = functions.https.onCall(async (data, context) => {
      if (context.app === undefined) { // If you want to use Firebase App Check to mitigate abuse
        throw new HttpsError(
          'failed-precondition',
          'Unrecognized caller');
      }
    
      if (!context.auth) {
        throw new HttpsError(
          'failed-precondition',
          'You must be authenticated to request a device code');
      }
    
      try {
        return {
          code: await createUserAuthCode(context.auth.uid)
        };
      } catch (error) {
        throw new HttpsError(
          'unknown',
          'Couldn\'t generate device code',
          { message: error.code || error.message }
        );
      }
    });
    
    const exchangeDeviceCode = functions.https.onRequest(async (req, res) => {
      if (req.method !== "GET") {
        console.log("Rejected unexpected " + req.method + " request");
        res.status(405)
          .set("Allow", "GET")
          .end();
        return;
      }
    
      const code = req.query.code;
    
      if (typeof code !== "string") {
        res.status(400)
          .json({ message: "Missing code param" });
        return;
      }
    
      try {
        const uid = await validateUserAuthCode(code);
    
        const token = await admin.auth()
          .createCustomToken(uid, {
            isDeviceToken: true // by having this, you can prevent the watch
                                // auth tokens from doing privileged actions
          });
    
        const response = await fetch({
          url: "https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=[API_KEY]", // TODO: Replace with Web API key
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ token, returnSecureToken: true })
        });
    
        // idToken - Firebase ID token (access token)
        // refreshToken - refresh token for this device authentication token
        // expiresIn - number of seconds to ID token expiry
    
        res
          .status(response.status)
          .set("Content-Type", "application/json")
          .send(response.text());
      } catch (err) {
        res.status(500)
          .json({ error: "Encountered unexpected error" });
      }
    });
    

    On the client side, you'd call the first function (after signing in) using either:

    // Java
    var getDeviceCodeFunc = FirebaseFunctions.getInstance().getHttpsCallable("getDeviceCode")
    
    getDeviceCodeFunc.call()
      .addOnCompleteListener({ task ->
        if (task.isSuccessful()) {
          // got code!
        } else {
          // failed!
        }
      });
    
    // Web/JavaScript
    const getDeviceCode = firebase.functions().httpsCallable("getDeviceCode");
    const code = await getDeviceCode();
    

    Then once the user has put in the code, send it off to

    GET https://us-central1-[PROJECT_ID].cloudfunctions.net/exchangeDeviceCode?code=[TYPED_CODE]
    

    Option 2: PKCE

    In this method, you perform the following steps:

    1. [Watch] Start sendAuthorizationRequest() flow
    2. [Web page] Authenticate user (if needed) and request permission to connect device
    3. [Cloud Function] Parse allow/deny request from previous step and generate a custom authentication token for that user
    4. [Cloud Function] Exchange the custom authentication token for a Firebase ID token and redirect to https://wear.googleapis.com/3p_auth/com.your.package.name with the GET parameters accessToken and refreshToken.
    5. [Watch] Parse the response

    Note: This is probably overkill for what you are trying to do. But if you really don't want someone having to type in a code on their watch it is available as an option. You could use oauth2-server to just proxy issuing Firebase ID tokens (access tokens).