skip to Main Content

I am working on sign in with Apple on Flutter Android.

Currently, sign in with Apple with Firebase Auth does not work on Flutter Android. See this issue.

However, following this guideline, I could successfully sign in with Apple using the sample Glitch callback project.

But I want to implement this custom server-side callback on Firebase Functions.

Below is my code for Firebase Functions. It loads Apple sign in screen but when I log on that screen and select Continue on the next page, it redirects to Google sign in screen unlike Glitch project which redirects to my Flutter app.

I believe I followed all the instructions from the Firebase and sign_in_with_apple docs because it works well with Glitch project.

I don’t have much knowledge about node.js and Express.

Please point me out what went wrong.

const functions = require("firebase-functions");
const admin = require('firebase-admin');
admin.initializeApp();

const express = require("express");
const AppleAuth = require("apple-auth");
const jwt = require("jsonwebtoken");
const bodyParser = require("body-parser");
const package_name = "my.package.name";
const team_id = "myteamid";
const service_id = "myserviceid";
const bundle_id = "mybundleid";
const key_id = "mykeyid";
const key_contents = 
`
MIGTAgEAMBMGByqGSM49234CCqGSM49AwEHBHkwdwIBAQQg27klLz6CSd30LJhs
...............................................................
mTfM2jUHZzQGiAySweGo7BmigwasdBvToiLErq4YJldATys1zSRNpWnSB//RAYRa
gyMCp94Y
`;

const app = express();

app.use(bodyParser.urlencoded({ extended: false }));

// make all the files in 'public' available
// https://expressjs.com/en/starter/static-files.html
app.use(express.static("public"));

// https://expressjs.com/en/starter/basic-routing.html
app.get("/", (request, response) => {
    res.send('Got GET request');
});

// The callback route used for Android, which will send the callback parameters from Apple into the Android app.
// This is done using a deeplink, which will cause the Chrome Custom Tab to be dismissed and providing the parameters from Apple back to the app.
app.post("callbacks/sign_in_with_apple", (request, response) => {
  const redirect = `intent://callback?${new URLSearchParams(request.body).toString()}#Intent;package=${package_name};scheme=signinwithapple;end`;

  console.log(`Redirecting to ${redirect}`);

  response.redirect(307, redirect);
});

// Endpoint for the app to login or register with the `code` obtained during Sign in with Apple
//
// Use this endpoint to exchange the code (which must be validated with Apple within 5 minutes) for a session in your system
app.post("/sign_in_with_apple", async (request, response) => {
  const auth = new AppleAuth(
    {
      // use the bundle ID as client ID for native apps, else use the service ID for web-auth flows
      // https://forums.developer.apple.com/thread/118135
      client_id:
        request.query.useBundleId === "true"
          ? bundle_id
          : service_id,
      team_id: team_id,
      redirect_uri:
        "https://us-central1-project-id.cloudfunctions.net/callbacks/sign_in_with_apple", // does not matter here, as this is already the callback that verifies the token after the redirection
      key_id: key_id
    },
    key_contents.replace(/|/g, "n"),
    "text"
  );

  console.log(`request query = ${request.query}`);

  const accessToken = await auth.accessToken(request.query.code);

  const idToken = jwt.decode(accessToken.id_token);

  const userID = idToken.sub;

  console.log(`idToken = ${idToken}`);

  const sessionID = `NEW SESSION ID for ${userID} / ${idToken.email}`;

  console.log(`sessionID = ${sessionID}`);

  response.json({sessionId: sessionID});
});

exports.sign_in_with_apple = functions.https.onRequest(app);

Here’s my package.json

{
  "name": "functions",
  "description": "Cloud Functions for Firebase",
  "scripts": {
    "lint": "eslint",
    "serve": "firebase emulators:start --only functions",
    "shell": "firebase functions:shell",
    "start": "npm run shell",
    "deploy": "firebase deploy --only functions",
    "logs": "firebase functions:log"
  },
  "engines": {
    "node": "16"
  },
  "main": "index.js",
  "dependencies": {
    "firebase-admin": "^10.0.2",
    "firebase-functions": "^3.18.0",
    "apple-auth": "1.0.7",
    "body-parser": "1.19.0",
    "express": "^4.17.1",
    "jsonwebtoken": "^8.5.1"
  },
  "devDependencies": {
    "eslint": "^8.9.0",
    "eslint-config-google": "^0.14.0",
    "firebase-functions-test": "^0.2.0"
  },
  "private": true
}

And this is the code in my Flutter app.

Future<void> signInWithApple(BuildContext context) async {
    // To prevent replay attacks with the credential returned from Apple, we
    // include a nonce in the credential request. When signing in with
    // Firebase, the nonce in the id token returned by Apple, is expected to
    // match the sha256 hash of `rawNonce`.
    final rawNonce = generateNonce();
    final nonce = sha256ofString(rawNonce);

    // Request credential for the currently signed in Apple account.
    final appleCredential = await SignInWithApple.getAppleIDCredential(
      scopes: [
        AppleIDAuthorizationScopes.email,
        AppleIDAuthorizationScopes.fullName,
      ],
      webAuthenticationOptions: WebAuthenticationOptions(
        clientId: 'my.service.id',
        redirectUri: Uri.parse('https://us-central1-project-name.cloudfunctions.net/sign_in_with_apple'),
      ),
      nonce: nonce,
    );

    // Create an `OAuthCredential` from the credential returned by Apple.
    final credential = OAuthProvider("apple.com").credential(
      idToken: appleCredential.identityToken,
      rawNonce: rawNonce,
      accessToken: '123'  // why this? https://github.com/firebase/flutterfire/issues/8865
    );

    debugPrint('Credential : $credential');

    // Sign in the user with Firebase. If the nonce we generated earlier does
    // not match the nonce in `appleCredential.identityToken`, sign in will fail.
    try {
      await _authInstance.signInWithCredential(credential);
    } on FirebaseAuthException catch (error) {
      _showErrorDialog(error, context);
    }
  } 

3

Answers


  1. Chosen as BEST ANSWER

    Based on the @GGirotto and @Monkey Drone's answer, I am posting full Cloud Function code that works.

    index.js

    const functions = require("firebase-functions");
    const admin = require('firebase-admin');
    admin.initializeApp();
    
    const express = require("express");
    const bodyParser = require("body-parser");
    const package_name = "my.package.name";
    
    const app = express();
    
    app.use(bodyParser.urlencoded({ extended: false }));
    
    // make all the files in 'public' available
    // https://expressjs.com/en/starter/static-files.html
    app.use(express.static("public"));
    
    // The callback route used for Android, which will send the callback parameters from Apple into the Android app.
    // This is done using a deeplink, which will cause the Chrome Custom Tab to be dismissed and providing the parameters from Apple back to the app.
    app.post("/", (request, response) => {
      const redirect = `intent://callback?${new URLSearchParams(request.body).toString()}#Intent;package=${package_name};scheme=signinwithapple;end`;
    
      console.log(`Redirecting to ${redirect}`);
    
      response.redirect(307, redirect);
    });
    
    
    exports.sign_in_with_apple = functions.https.onRequest(app);
    

    .eslintrc.js

    module.exports = {
      root: true,
      env: {
        es6: true,
        node: true,
      },
      extends: [
        "eslint:recommended",
      ],
      rules: {
        quotes: ["error", "double"],
        "no-unused-vars": "warn"
      },
    };
    

    package.json

    {
      "name": "functions",
      "description": "Cloud Functions for Firebase",
      "scripts": {
        "lint": "eslint",
        "serve": "firebase emulators:start --only functions",
        "shell": "firebase functions:shell",
        "start": "npm run shell",
        "deploy": "firebase deploy --only functions",
        "logs": "firebase functions:log"
      },
      "engines": {
        "node": "16"
      },
      "main": "index.js",
      "dependencies": {
        "firebase-admin": "^10.0.2",
        "firebase-functions": "^3.18.0",
        "body-parser": "1.19.0",
        "express": "^4.17.1"
      },
      "devDependencies": {
        "eslint": "^8.9.0",
        "eslint-config-google": "^0.14.0",
        "firebase-functions-test": "^0.2.0"
      },
      "private": true
    }
    

  2. Not sure if you need the /sign_in_with_apple function you wrote, since the app already generates a session id through the SDK. Try the following in your server-side:

    const functions = require("firebase-functions");
    const admin = require('firebase-admin');
    admin.initializeApp();
    
    const express = require("express");
    const bodyParser = require("body-parser");
    const package_name = "my.package.name";
    
    const app = express();
    
    app.use(bodyParser.urlencoded({ extended: false }));
    
    // make all the files in 'public' available
    // https://expressjs.com/en/starter/static-files.html
    app.use(express.static("public"));
    
    // https://expressjs.com/en/starter/basic-routing.html
    app.get("/", (request, response) => {
        res.send('Got GET request');
    });
    
    // The callback route used for Android, which will send the callback parameters from Apple into the Android app.
    // This is done using a deeplink, which will cause the Chrome Custom Tab to be dismissed and providing the parameters from Apple back to the app.
    app.post("/sign_in_with_apple", (request, response) => {
      const redirect = `intent://callback?${new URLSearchParams(request.body).toString()}#Intent;package=${package_name};scheme=signinwithapple;end`;
    
      console.log(`Redirecting to ${redirect}`);
    
      response.redirect(307, redirect);
    });
    
    exports.sign_in_with_apple = functions.https.onRequest(app);
    
    Login or Signup to reply.
  3. The solution by GGirotto totally works. I initially ran into issues implementing it because I had no idea how JavaScript works. So I am writing this for people who might run into the same issues as me.
    If you had forced lint when installing / initializing cloud functions on your system, it will add ‘google’ in the lint options under the file ".eslintrc.js"

    The lint was telling me to make some changes such as replacing the BACKTICKS `
    with double apostrophes ".

    Now this made the variables inside the strings unable to be processed properly.
    Those backticks ` are CRITICAL. All in all, you need to disable google in the lint enforcement file ‘.eslintrc.js"
    This is what my file now looks like.

    module.exports = {
      root: true,
      env: {
        es6: true,
        node: true,
      },
      extends: [
        "eslint:recommended",
        // "google",
      ],
      rules: {
        quotes: ["error", "double"],
      },
    };
    

    As soon as I did that, everything started working. Also you need to make some minor changes such as the app.post part. It is now

    app.post("/", (request, response) => {
      var redirect = `intent://callback?${new URLSearchParams(request.body).toString()}#Intent;package=${package_name};scheme=signinwithapple;end`;
    
      console.log(`Redirecting to ${redirect}`);
    
      response.redirect(307, redirect);
    });
    

    Also you don’t really need the app.get part. I disabled it. Everything works well still. I do believe that part is just for people who might browse to that url in a browser. Not needed for this specific cloud function.

    I’m hoping to implement App Check on the cloud function next and hopefully it all goes well. All the best everyone.
    Thank you again GGirotto for your contribution, it has helped immensely!
    Much appreciated.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search