How to use Naver for SSO on Android and iOS Flutter apps with Firebase

다니엘·2023년 12월 21일
1

Firebase Login

목록 보기
1/2
post-thumbnail

The subject is complicated, but you will learn a lot, and there's a big debugging section at the bottom which will help you fix any errors you have! ~~

0. Content

This guide is specific for those developing apps with Firebase (Google Cloud also works directly, but I will continue to refer to Firebase) and having Firebase handle authentication.

While Naver and Firebase both support SAML (which should make the integration as easy as Kakao, Flutterfire (the Flutter SDK for Firebase) does not support SAML for Android/iOS apps - So we have to do a lot of the work manually.

While other guides for Flutter + Firebase + Naver exist, they're hard to follow, and sometimes insecure! This guide will go over absolutely everything.

1. Register Application, get Client ID and Secret

  • Go to https://developers.naver.com/apps/ and click "Application 등록"
  • Set "사용 API" to "네이버 로그인"
  • Set "로그인 오픈 API 서비스 환경" to "Mobile 웹"
  • Set "서비스 URL" to https://your-firebase-project-id.firebaseapp.com
    • This is a URL your app "owns" (even if it's a 404). Try going to it in the browser!
  • Set "네이버 로그인 Callback URL (최대 5개)" to https://us-central1-your-firebase-project-id.cloudfunctions.net/naverLoginCallback
    • This is a Cloud Function - It is needed to handle the Client Secret securely. We will make it later!
  • Fill out any more information you want, then click 등록하기

You can now find your Client ID and Client Secret in the 개요 tab of your Application.

2. Write the Flutter Code

Future<void> signInWithNaver() async {
  final String clientId = 'yourNaverClientId';
  final String redirectUri = 'https://uscentral-1-your-firebase-project-id.cloudfunctions.net/naverLoginCallback'; // We will create this on Google Cloud
  final String state = base64Url.encode(List<int>.generate(16, (_) => Random().nextInt(255))); // Random nonce used for security
  final Uri url = Uri.parse('https://nid.naver.com/oauth2.0/authorize?response_type=code&client_id=$clientId&redirect_uri=$redirectUri&state=$state');
  print("Opening Naver SSO (Naver will call our Cloud Function which will use Callback Scheme to re-enter app..");
  await launchUrl(url); // flutter pub get url_launcher
}

StreamSubscription? _sub;

// This is where our app will start up again after the Cloud Function runs
Future<void> initUniLinks() async {
  final initialLink = await getInitialLink(); // flutter pub get uni_links
  // Handle the initial link if it exists
  if (initialLink != null) _handleDeepLink(initialLink);

  // This will handle the link that the app receives while already opened
  linkStream.listen((String? link) {
    _handleDeepLink(link!);
  }, onError: (err, stacktrace) {
    print("Error while listening to deep links: $err\n$stacktrace");
  });
}

Future<void> _handleDeepLink(String link) async {
  print("Handling deep link $link");
  final Uri uri = Uri.parse(link);

  if (uri.authority == 'login-callback') {
    final firebaseToken = uri.queryParameters['firebaseToken'];
    final String? name = uri.queryParameters['name'];
    final String? profileImage = uri.queryParameters['profileImage'];

    UserCredential userCredential = await FirebaseAuth.instance.signInWithCustomToken(firebaseToken!);
    print('Signed in user ${userCredential.user}');

    userCredential.user!.updateDisplayName(name);
    userCredential.user!.updatePhotoURL(profileImage);
  }
}

// This can be changed to whatever your first screen's widget is
class YourMainClass extends StatefulWidget {
  const YourMainClass({Key? key}) : super(key: key);

  
  _YourMainClassState createState() => _YourMainClassState();
}

// Again, use whatever your main Widget is here
class _YourMainClassState extends State<YourMainClass> {
  
  void initState() {
    super.initState();
 	print("Initing Uni Links - it's now possible for the app to know the yournaverauthcallbackscheme was used!");
    initUniLinks();
  }

  
  void dispose() {
    _sub?.cancel();
    super.dispose();
  }
  
  
  Widget build(BuildContext context) {
    return ElevatedButton(
        child: Text("Naver Login"),
        onPressed: () async {
          await signInWithNaver();
        });
  }
}

3. Add the Callback URI Schemes

Every app needs a unique string that browsers can call to open it up. Replace yournaverauthcallbackscheme in the following section with something related to your app (in my app, 직음이직, I use nownownaver).

Add the string in the following 2 places:

AndroidManifest.xml: manifest > application > activity:

<activity android:name=".MainActivity">
    <!-- ... other configurations ... -->

    <!-- Add the following intent-filter within the <activity> tag -->
    <!-- Copy this whole <intent-filter> below any other <intent-filter>'s you already have! -->
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <!-- Must be lower case! -->
        <data android:scheme="yournaverauthcallbackscheme" />
    </intent-filter>
</activity>

info.plist: under CFBundleURLTypes > array/dict > CFBundleURLSchemes > array:

<key>CFBundleURLTypes</key>
<array>
  <dict>
  	<!-- Other keys -->
    <key>CFBundleURLSchemes</key>
    <array>
      <!-- Add this <string> with your custom lowercase scheme here! -->
      <string>yournaverauthcallbackscheme</string>
    </array>
  </dict>
</array>

Fun fact! If you do a fresh app compile, you will be able to visit the URI yournaverauthcallbackscheme://random and it will open up your app! (Again, remember to replace every yournaverauthcallbackscheme with the string you made up)

4. Write the Cloud Function

The Cloud Function is the secret ingrediant to making this work. It's as easy as it sounds - We'll write some code, and be able to run it on "the cloud" without worrying about servers (and it's much more secure).

Because the Naver docs have an example with NodeJS, I'm going to use NodeJS for the Cloud Function.

  • Go to https://console.cloud.google.com/functions and click "Create Function"
  • Set the function name to naverLoginCallback
  • Set the region to us-central1
    (Note: Because we put "us-central1" in the "네이버 로그인 Callback URL", we have to keep the function region in "us-central1". If you want a different region, you can edit your API 설정)
  • Keep the Trigger Type as HTTPS
  • Select "Allow unauthenticated invocations"
  • Expand Runtime, build connections and security settings > Set the Service Account to "firebase-adminsdk"
  • Click Next, so that you see the "Code" screen
  • Set your Runtime to "Node.js 20", and set Source code to "Inline Editor"
  • Set the "Entry point" to main (We'll write the "main" function next)

Change package.json to

{
  "dependencies": {
    "@google-cloud/functions-framework": "^3.0.0",
    "firebase-admin": "^9.12.0",
    "cors": "^2.8.5",
    "node-fetch": "^2.6.7"
  }
}

And change index.js to

const functions = require('@google-cloud/functions-framework');
const admin = require('firebase-admin');
const cors = require('cors')({ origin: true });
const fetch = require('node-fetch');

admin.initializeApp();

// This function will be called every time someone logs in
functions.http('main', (req, res) => {
  console.log("Successfully hit the cloud function..");
  cors(req, res, async () => {
    const { code, state } = req.query;
    
    // CHANGE THESE!!!
    const client_id = your=client-id;
    const client_secret = your-client-secret; // If you want to be extra secure, put this in a Google Cloud Secret
    const redirect_uri = 'yournaverauthcallbackscheme://login-callback';
    
    try {
      const accessToken = await fetchNaverToken(code, state, client_id, client_secret, redirect_uri); // A token that lets us ask Naver about the user's details
      const naverProfile = await fetchNaverUserProfile(accessToken);
      const firebaseToken = await createFirebaseToken(naverProfile); // JWT token that says we authenticated the user, so Firebase can trust anyone who has it

      // This is what opens your app back up
      res.redirect(`${redirect_uri}?firebaseToken=${firebaseToken}&name=${encodeURIComponent(naverProfile.name)}&profileImage=${encodeURIComponent(naverProfile.profile_image)}`);
    } catch (error) {
      console.error('Error processing the authentication:', error);
      res.status(500).send(error.message);
    }
  });
});

async function fetchNaverToken(code, state, client_id, client_secret, redirect_uri) {
  const tokenUrl = `https://nid.naver.com/oauth2.0/token?grant_type=authorization_code&client_id=${client_id}&client_secret=${client_secret}&redirect_uri=${redirect_uri}&code=${code}&state=${state}`;

  const response = await fetch(tokenUrl);
  const data = await response.json();

  if (!response.ok) {
    throw new Error(`Error from Naver: ${response.status} ${response.statusText}`);
  }
  
  if (data.error) {
    throw new Error(`Error from Naver: ${data.error} ${data.error_description}`);
  }
  
  return data.access_token;
}

async function fetchNaverUserProfile(accessToken) {
  const profileUrl = 'https://openapi.naver.com/v1/nid/me';

  const response = await fetch(profileUrl, {
    headers: {
      'Authorization': `Bearer ${accessToken}`
    }
  });
  const data = await response.json();

  if (!response.ok) {
    throw new Error(`Error fetching Naver user profile: ${response.status} ${response.statusText}`);
  }

  if (data.error) {
    throw new Error(`Error from Naver: ${data.error} ${data.error_description}`);
  }

  console.log(`Got the following data from Naver: ${JSON.stringify(data.response)}`);
  return data.response;
}

async function createFirebaseToken(naverProfile) {
  const uid = naverProfile.id;

  try {
    await admin.auth().getUser(uid);
  } catch (error) {
    // If the user does not exist, create them
    if (error.code === 'auth/user-not-found') {
      await admin.auth().createUser({
        uid: uid,
        displayName: naverProfile.name,
        photoURL: naverProfile.profile_image,
        email: naverProfile.email,
        emailVerified: true
      });
    } else {
      // If another error occurred, throw it
      throw error;
    }
  }

  // Create a JWT token that the user can use to prove they logged in
  return admin.auth().createCustomToken(uid);
}
  • Change yournaverauthcallbackscheme in the code to your custom Naver auth callback scheme.
  • Change client_id to your Client ID under 애플리케이션 정보 on Naver
  • Change your-client-secret; to your Client Secret under 애플리케이션 정보 on Naver

Click Deploy, and wait for all the spinners to stop.

Understanding Everything

The login process is quite simple:

  1. The user goes to a Naver URL and signs in
  2. Naver goes to the Cloud Function and says "Yup, user signed in"
  3. Cloud Function creates the user account in Firebase, then opens your app again

At each stage (1 -> 2 -> 3), different information is passed:

  1. The user passes the Client ID (the app ID you made on developers.naver.com), the Cloud Function that Naver should call, and a random "State" that is used for security
  2. Naver passes a "Token" to the Cloud Function that can be used to access information about the user on Naver
  3. The Cloud Function passes a "Token" called a "JWT" to your app, which lets you call FirebaseAuth.instance.signInWithCustomToken

Debugging

Tips:

Sign in with Naver says 페이지를 찾을 수 없습니다

Sign in with Naver says Unable to log in to <your app> or You cannot log in with your NAVER account due to an error in the <your app> service. If the same problem persists, please contact the <your app> admin.

  • Make sure your signInWithNaver() has the correct redirectUri (the Cloud Function URL we made in step 4)
  • Make sure your API 설정 -> 네이버 로그인 Callback URL has the correct redirectUri

App doesn't open again after logging into Naver (App has a blank page after logging in to Naver)

  • Test your Callback URI scheme with this tool I made: uri-validator.github.io
  • Make sure your 네이버 로그인 Callback URL in the API 설정 is correct

App says Error: Forbidden or Your client does not have permission to get URL /naverLoginCallback?code=... from this server after logging in with Naver

  • You did not select "Allow unauthenticated invocations" when creating the Cloud Function. This cannot be modified, so you must delete the Cloud Function and do Step 4 again

App says "Internal Server Error" after signing in

  • You probably didn't follow the steps to change the variables after making the Cloud Function
  • Go to the Logs of your Cloud Function and see what's up

App is blank page after signing in

  • Your callback scheme is incorrect - Follow Step 3

App doesn't do anything after opening up again

  • You're not calling initUniLinks()

Getting Error: Page not found after signing in

  • Your Cloud Function URL is wrong. Check your Google Cloud function URL, then make sure it matches both in your Flutter code and on Naver

IAM Service Account Credentials API has not been used in project ##### before or it is disabled.

  • As the error suggests, enable the IAM Service Account Credentials API in the Google Cloud console

Permission 'iam.serviceAccounts.signBlob' denied on resource (or it may not exist).

  • Edit your cloud function and set the service account to "firebase-adminsdk"
  • Alternatively, you can add the signBlob permission to whichever service account you are already using
profile
Full Stack Developer

0개의 댓글

관련 채용 정보