๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ
iOS ๐ŸŽ/iOS

Sign in with Apple ํ’€์Šคํƒ(iOS + Node.js) ๊ฐœ๋ฐœ ๋…ธํŠธ

by yongmin.Lee 2022. 10. 12.

์• ํ”Œ๋กœ๊ทธ์ธ๊ธฐ๋Šฅ์„ ํด๋ผ์ด์–ธํŠธ(iOS)์™€ ๋ฐฑ์—”๋“œ(Node.js)๋กœ ํ’€์Šคํƒ ๊ฐœ๋ฐœํ•˜๋ ค ํ–ˆ๋Š”๋ฐ

iOS ๊ฐœ๋ฐœํ•˜๋Š” ์ž๋ฃŒ๋Š” ๋ฐฑ์—”๋“œ๋ฅผ ์–ด๋–ป๊ฒŒ ๊ตฌํ˜„ ํ–ˆ๋Š”์ง€ ๋‚˜์˜ค์ง€ ์•Š๊ณ , ๋ฐฑ์—”๋“œ๋ฅผ ๊ฐœ๋ฐœํ•˜๋Š” ์ž๋ฃŒ๋Š” iOS๋ฅผ ์–ด๋–ป๊ฒŒ ๊ตฌํ˜„ํ•˜๋Š”์ง€ ๋‚˜์˜ค์ง€ ์•Š๊ณ , ํ’€์Šคํƒ ๊ฐœ๋ฐœ ์ž๋ฃŒ๋Š” ์ „๋ถ€ iOS๊ฐ€ ์•„๋‹ˆ๋ผ ์›น+๋ฐฑ์—”๋“œ ํ’€์Šคํƒ ์ž๋ฃŒ๋“ค์ธ๋ฐ๋‹ค๊ฐ€ ์ž๋ฃŒ๋“ค์ด ์ „๋ถ€ ์˜ค๋ž˜๋˜์„œ ๋งž์ง€ ์•Š๋Š”๊ฒŒ ๋„ˆ๋ฌด ๋งŽ์•˜๋‹ค.

๊ทธ๋ž˜์„œ ์‚ฝ์งˆ์— ์‚ฝ์งˆ์„ ๊ฒช์œผ๋ฉฐ ๊ฐœ๋ฐœ์„ ํ–ˆ๋Š”๋ฐ ํ’€์Šคํƒ ๊ฐœ๋ฐœ ํ•˜๋Š” ๋ˆ„๊ตฐ๊ฐ€๋Š” ๊ทธ๋Ÿฐ ๊ณ ํ†ต์„ ๊ฒช์ง€ ์•Š๊ธฐ๋ฅผ ๋ฐ”๋žŒ!


๋ชฉ์ฐจ

1. ์ค€๋น„

2. iOS ํด๋ผ์ด์–ธํŠธ ๊ฐœ๋ฐœ

3. Node.js ๋ฐฑ์—”๋“œ ๊ฐœ๋ฐœ

4. ๋งˆ๋ฌด๋ฆฌ

 


์ค€๋น„

1. Apple Developer์—์„œ App ID ์„ค์ •

  • Certificates, Identifiers & Profiles >  Identifiers > App IDs ์ถ”๊ฐ€
  • ๋ฒˆ๋“ค ์•„์ด๋””๋Š” ์• ํ”Œ ๋กœ๊ทธ์ธ์„ ๋ถ™์—ฌ์ฃผ๊ธฐ ์œ„ํ•ด ๋งŒ๋“ค์–ด์ค€ ์•ฑ์˜ Bundle Identifier๋กœ ์ง€์ •
  • Capabilities ํ•ญ๋ชฉ์ค‘์—์„œ ์•„๋ž˜๋กœ ๋‚ด๋ ค Sign In With Apple ํ•ญ๋ชฉ์„ ์„ ํƒ
  • Sign In With Apple ํ•ญ๋ชฉ edit์—์„œ App ID ๊ทธ๋ฃน, Server to Server Notification Endpoint๋Š” ๋”ฐ๋กœ ์„ค์ •ํ•˜์ง€ ์•Š๊ณ  ์ €์žฅ

2. Apple Developer์—์„œ Key ์„ค์ •

  • Certificates, Identifiers & Profiles >  Keys > Sign In With Apple ํ•ญ๋ชฉ์„ ์„ ํƒ
  • ์œ„์—์„œ ๋“ฑ๋กํ•œ App ID ์„ ํƒํ•˜๋ฉด ์ธ์ฆํ‚ค ๋ฐœ๊ธ‰
  • ์ธ์ฆํ‚ค(.p8 ํŒŒ์ผ)๋Š” ํ•œ๋ฒˆ๋งŒ ๋‹ค์šด๋กœ๋“œ ํ• ์ˆ˜์žˆ์œผ๋ฏ€๋กœ ์žƒ์–ด๋ฒ„๋ฆฌ์ง€ ์•Š๋„๋ก ์ฃผ์˜!

3.  Capabilities ์ถ”๊ฐ€

  • xcode ์•ฑ ํƒ€๊ฒŸ์—์„œ Signing&Capabilities
  • ์—ฌ๊ธฐ์„œ ์ขŒ์ธก ์ƒ๋‹จ + ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ Sign in with Apple ํ•ญ๋ชฉ์„ ์ถ”๊ฐ€

์ดˆ๊ธฐ์„ค์ • ๋! (์›น์„œ๋น„์Šค๋ฅผ ๋งŒ๋“œ๋Š”๊ฒŒ ์•„๋‹ˆ๋ฉดApple Developer์—์„œ Services๋Š” ๋“ฑ๋กํ•  ํ•„์š”์—†์Œ)


iOS ํด๋ผ์ด์–ธํŠธ ๊ฐœ๋ฐœ

1. ๋ฒ„ํŠผ์„ ๋งŒ๋“ค๊ณ  ํƒญํ•˜์˜€์„๋•Œ viewModel์˜ ๋กœ๊ทธ์ธ ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•˜๋„๋ก action ์ •์˜

(Info.plist -> Information Property List -> Localization native development region์„ Korea๋กœ ๋ณ€๊ฒฝํ•˜๋ฉด ์• ํ”Œ๋กœ๊ทธ์ธ ๋ฒ„ํŠผ์ด ํ•œ๊ตญ์–ด๋กœ ๋‚˜์˜จ๋‹ค!)

// ์• ํ”Œ ๋กœ๊ทธ์ธ ๋ฒ„ํŠผ
private let appleBtn: ASAuthorizationAppleIDButton = {
    let btn = ASAuthorizationAppleIDButton(
        authorizationButtonType: .signIn,
        authorizationButtonStyle: .black
    )
    return btn
}()

// ์ค‘๋žต...

// ์• ํ”Œ๋กœ๊ทธ์ธ๋ฒ„ํŠผ ํƒญํ•˜๋ฉด ๋ทฐ๋ชจ๋ธ์˜ loginํ•จ์ˆ˜ ํ˜ธ์ถœ
self.appleBtn.rx.tapGesture()
    .when(.recognized)
    .asDriver { _ in .empty() }
    .drive(onNext: { [weak self] _ in
        self?.welcomeViewModel.login(provider: .apple)
    })
    .disposed(by: self.disposeBag)

2. ASAuthorizationControllerDelegate ๊ตฌํ˜„

Delegate Proxy๋ฅผ ํ™œ์šฉํ•ด์„œ ASAuthorizationControllerDelegate์˜ didCompleteWithAuthorization๋ฅผ Rx๋กœ ๊ตฌํ˜„ํ•จ

// Delegate Proxy ๊ตฌํ˜„
final class RxASAuthorizationControllerDelegateProxy:
    DelegateProxy<ASAuthorizationController, ASAuthorizationControllerDelegate>,
    DelegateProxyType,
    ASAuthorizationControllerDelegate
{
    static func registerKnownImplementations() {
        self.register {
            RxASAuthorizationControllerDelegateProxy(
                parentObject: $0,
                delegateProxy: RxASAuthorizationControllerDelegateProxy.self
            )
        }
    }
    
    static func currentDelegate(for object: ASAuthorizationController) -> ASAuthorizationControllerDelegate? {
        object.delegate
    }
    
    static func setCurrentDelegate(_ delegate: ASAuthorizationControllerDelegate?, to object: ASAuthorizationController) {
        object.delegate = delegate
    }
}

extension Reactive where Base: ASAuthorizationController {
    var delegate: DelegateProxy<ASAuthorizationController, ASAuthorizationControllerDelegate> {
        RxASAuthorizationControllerDelegateProxy.proxy(for: base)
    }
    
    var didCompleteAuthorization: Observable<Authentication?> {
        delegate
            .methodInvoked(#selector(ASAuthorizationControllerDelegate.authorizationController(controller:didCompleteWithAuthorization:)))
            .map { parameters in
                guard let authorization = parameters[1] as? ASAuthorization,
                      let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
                      let identityTokenData = credential.identityToken,
                      let authorizationCodeData = credential.authorizationCode,
                      let identityToken = String(data: identityTokenData, encoding: .utf8),
                      let authorizationCode = String(data: authorizationCodeData, encoding: .utf8)
                else {
                    Crashlytics.crashlytics().record(
                        error: CustomError(memo: "didCompleteWithAuthorization with nil")
                    )
                    return nil
                }
                
                // ์ฒซ๋ฒˆ์งธ ํšŒ์›๊ฐ€์ž… ์„ฑ๊ณต์ดํ›„ ๋ถ€ํ„ฐ ์• ํ”Œ๋กœ๊ทธ์ธ์‹œ์—๋Š” ๋นˆ๊ฐ’์œผ๋กœ ๋„˜์–ด์˜ด
                let givenName = credential.fullName?.givenName ?? ""
                let userEmail = credential.email ?? ""
                
                return .init(
                    accessToken: nil,
                    refreshToken: nil,
                    identityToken: identityToken,
                    authorizeCode: authorizationCode,
                    snsUserName: givenName,
                    snsUserEmail: userEmail,
                    snsUserId: credential.user
                )
            }
    }
}

3. ์• ํ”Œ๋กœ๊ทธ์ธ ๊ตฌํ˜„

func appleAuthorize() -> Observable<Authentication> {
    let request = ASAuthorizationAppleIDProvider().createRequest()
    request.requestedScopes = [.fullName, .email]
    let controller = ASAuthorizationController(authorizationRequests: [request])
    controller.performRequests()
        
    // ์—ฌ๊ธฐ์„œ ๋ฆฌํ„ด๋˜๋Š” authorizeCode, identityToken, userId๋“ฑ ์ •๋ณด๋ฅผ ์„œ๋ฒ„์— ๋ณด๋‚ด์ฃผ๋Š”๋ฐ
    // ์• ํ”Œ์˜ accessToken๊ณผ refreshToken์„ ๋ฐœ๊ธ‰ ๋ฐ›๋Š”๋ฐ๋Š” authorizeCode๊ฐ€ ํ•„์š”ํ•˜๋‹ค
    return controller.rx.didCompleteAuthorization
        .filterNil()
        .first()
        .asObservable()
        .filterNil()
}

4. node ๋ฐฑ์—”๋“œ ์„œ๋ฒ„์— ์• ํ”Œ๋กœ๊ทธ์ธ ์ •๋ณด๋ฅผ ๋„˜๊ฒจ์ฃผ๋ฉด ํด๋ผ์ด์–ธํŠธ ๋!


Node.js ๋ฐฑ์—”๋“œ ๊ฐœ๋ฐœ

1. ํด๋ผ์ด์–ธํŠธ๋กœ๋ถ€ํ„ฐ authorizeCode๋ฅผ ๋ฐ›์œผ๋ฉด ์• ํ”Œ์„œ๋ฒ„๋กœ ๋ถ€ํ„ฐ accessToken๊ณผ refreshToken์„ ๋ฐœ๊ธ‰ํ•œ๋‹ค

const axios = require("axios");
const { response } = require("express");
const { user } = require("../../models");
const fs = require("fs");
const querystring = require('querystring');
const jwt_decode = require("jwt-decode");
const jwt = require("jsonwebtoken");
const jwksClient = require("jwks-rsa");

module.exports = async (req, res) => {
  const { authorizeCode } = req.body; // ํด๋ผ์ด์–ธํŠธ์—์„œ ๋ฐ›์€ authorizeCode

  try {
  
    /*--------------------- 1. generate access token, refresh token -------------------------*/

    // make client_secret
    const algorithm = process.env.ALG;  // ์•Œ๊ณ ๋ฆฌ์ฆ˜ : ES256
    const keyid = process.env.KID;  // [Key ID]
    const issuer = process.env.ISS;  // [ํŒ€ ID = App ID Prefix]
    const expiresIn = 15777000;         // ํ† ํฐ๋งŒ๋ฃŒ์‹œ๊ฐ„ 6๊ฐœ์›”(์ดˆ),  ํ† ํฐ์ƒ์„ฑ์‹œ๊ฐ„ -> jwt.sign()์— ์ „๋‹ฌ์•ˆํ•ด์ฃผ๋ฉด ํ˜„์žฌ์‹œ๊ฐ„์œผ๋กœ ์„ธํŒ…
    const audience = "https://appleid.apple.com";
    const subject = process.env.SUB;  // [์•ฑ๋ฒˆ๋“ค์•„์ด๋””]
    const authkey = fs.readFileSync(process.env.AUTHKEY, 'utf8'); // Apple Developer์—์„œ ๋‹ค์šด๋กœ๋“œ๋ฐ›์€ .p8ํŒŒ์ผ

    const client_secret = jwt.sign(
      {},
      authkey,
      {
        algorithm: algorithm,
        keyid: keyid,
        issuer: issuer,
        audience: audience,
        subject: subject,
        expiresIn: expiresIn
      });

    const tokenresult = await axios.post(
      'https://appleid.apple.com/auth/token',
      querystring.stringify({
        grant_type: 'authorization_code',	
        code: authorizeCode,
        client_secret: client_secret,		
        client_id: process.env.SUB,			
      }),
      {
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
        },
      },
    );

	// ์• ํ”Œ์ด ๋ฐœ๊ธ‰ํ•ด์ค€ accessToken, refreshToken
    const accessToken = tokenresult.data.access_token;
    const refreshToken = tokenresult.data.refresh_token;

	// ์ค‘๋žต...
    
    
  } catch (e) {
    res.status(400).send(`err -> ${e}`);
  }
};

2. ์• ํ”Œ์ด ๋ฐœ๊ธ‰ํ•ด์ค€ refreshToken์€ ๋งŒ๋ฃŒ๊ธฐ๊ฐ„์ด ์—†๋Š”๋ฐ ๋‚˜์ค‘์— ํšŒ์›ํƒˆํ‡ด๋ฅผ ํ• ๋•Œ ํ•„์š”ํ•˜๋ฏ€๋กœ ํšŒ์›๊ฐ€์ž…์„ ์‹œํ‚ค๋ฉด์„œ ๋””๋น„์— ์ €์žฅํ•œ๋‹ค

const axios = require("axios");
const { response } = require("express");
const { user } = require("../../models");
const fs = require("fs");
const querystring = require('querystring');
const jwt_decode = require("jwt-decode");
const jwt = require("jsonwebtoken");
const jwksClient = require("jwks-rsa");
const AuthService = require('../../service/AuthService');

module.exports = async (req, res) => {
  const { 
  authorizeCode,  // ํด๋ผ์ด์–ธํŠธ์—์„œ ๋ฐ›์€ authorizeCode
  snsUserId       // ํด๋ผ์ด์–ธํŠธ์—์„œ ๋ฐ›์€ ์• ํ”Œ๋กœ๊ทธ์ธํ•œ ์œ ์ €์˜ ๊ณ ์œ ๋ฒˆํ˜ธ
  } = req.body; 

  try {
  
    // ์ค‘๋žต...
  
    /*--------------------- 1. generate access token, refresh token -------------------------*/

    // make client_secret
    const algorithm = process.env.ALG;  // ์•Œ๊ณ ๋ฆฌ์ฆ˜ : ES256
    const keyid = process.env.KID;  // [Key ID]
    const issuer = process.env.ISS;  // [ํŒ€ ID = App ID Prefix]
    const expiresIn = 15777000;         // ํ† ํฐ๋งŒ๋ฃŒ์‹œ๊ฐ„ 6๊ฐœ์›”(์ดˆ),  ํ† ํฐ์ƒ์„ฑ์‹œ๊ฐ„ -> jwt.sign()์— ์ „๋‹ฌ์•ˆํ•ด์ฃผ๋ฉด ํ˜„์žฌ์‹œ๊ฐ„์œผ๋กœ ์„ธํŒ…
    const audience = "https://appleid.apple.com";
    const subject = process.env.SUB;  // [์•ฑ๋ฒˆ๋“ค์•„์ด๋””]
    const authkey = fs.readFileSync(process.env.AUTHKEY, 'utf8'); // Apple Developer์—์„œ ๋‹ค์šด๋กœ๋“œ๋ฐ›์€ .p8ํŒŒ์ผ

    const client_secret = jwt.sign(
      {},
      authkey,
      {
        algorithm: algorithm,
        keyid: keyid,
        issuer: issuer,
        audience: audience,
        subject: subject,
        expiresIn: expiresIn
      });

    const tokenresult = await axios.post(
      'https://appleid.apple.com/auth/token',
      querystring.stringify({
        grant_type: 'authorization_code',	
        code: authorizeCode,
        client_secret: client_secret,		
        client_id: process.env.SUB,			
      }),
      {
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
        },
      },
    );

	// ์• ํ”Œ์ด ๋ฐœ๊ธ‰ํ•ด์ค€ accessToken, refreshToken
    const accessToken = tokenresult.data.access_token;
    const refreshToken = tokenresult.data.refresh_token;

    // ํšŒ์›๊ฐ€์ž…  
    const authService = new AuthService();
    const newUser = await authService.signIn(snsUserId, snsRefreshToken);

    
    
  } catch (e) {
    res.status(400).send(`err -> ${e}`);
  }
};

3. ์ƒˆ๋กœ ๊ฐ€์ž…ํ•œ ์œ ์ €์˜ id๋กœ ์„œ๋น„์Šค์˜  accessToken, refreshToken์„ ๋ฐœํ–‰ํ•ด์ฃผ๋ฉด ๋ฐฑ์—”๋“œ ์• ํ”Œ๋กœ๊ทธ์ธ ๊ตฌํ˜„ ์™„๋ฃŒ!

const axios = require("axios");
const { response } = require("express");
const { user } = require("../../models");
const fs = require("fs");
const querystring = require('querystring');
const jwt_decode = require("jwt-decode");
const jwt = require("jsonwebtoken");
const jwksClient = require("jwks-rsa");
const AuthService = require('../../service/AuthService');

module.exports = async (req, res) => {
  const { 
  authorizeCode,  // ํด๋ผ์ด์–ธํŠธ์—์„œ ๋ฐ›์€ authorizeCode
  snsUserId       // ํด๋ผ์ด์–ธํŠธ์—์„œ ๋ฐ›์€ ์• ํ”Œ๋กœ๊ทธ์ธํ•œ ์œ ์ €์˜ ๊ณ ์œ ๋ฒˆํ˜ธ
  } = req.body; 

  try {
  
    // ์ค‘๋žต...
  
    /*--------------------- 1. generate access token, refresh token -------------------------*/

    // make client_secret
    const algorithm = process.env.ALG;  // ์•Œ๊ณ ๋ฆฌ์ฆ˜ : ES256
    const keyid = process.env.KID;  // [Key ID]
    const issuer = process.env.ISS;  // [ํŒ€ ID = App ID Prefix]
    const expiresIn = 15777000;         // ํ† ํฐ๋งŒ๋ฃŒ์‹œ๊ฐ„ 6๊ฐœ์›”(์ดˆ),  ํ† ํฐ์ƒ์„ฑ์‹œ๊ฐ„ -> jwt.sign()์— ์ „๋‹ฌ์•ˆํ•ด์ฃผ๋ฉด ํ˜„์žฌ์‹œ๊ฐ„์œผ๋กœ ์„ธํŒ…
    const audience = "https://appleid.apple.com";
    const subject = process.env.SUB;  // [์•ฑ๋ฒˆ๋“ค์•„์ด๋””]
    const authkey = fs.readFileSync(process.env.AUTHKEY, 'utf8'); // Apple Developer์—์„œ ๋‹ค์šด๋กœ๋“œ๋ฐ›์€ .p8ํŒŒ์ผ

    const client_secret = jwt.sign(
      {},
      authkey,
      {
        algorithm: algorithm,
        keyid: keyid,
        issuer: issuer,
        audience: audience,
        subject: subject,
        expiresIn: expiresIn
      });

    const tokenresult = await axios.post(
      'https://appleid.apple.com/auth/token',
      querystring.stringify({
        grant_type: 'authorization_code',	
        code: authorizeCode,
        client_secret: client_secret,		
        client_id: process.env.SUB,			
      }),
      {
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
        },
      },
    );

	// ์• ํ”Œ์ด ๋ฐœ๊ธ‰ํ•ด์ค€ accessToken, refreshToken
    const accessToken = tokenresult.data.access_token;
    const refreshToken = tokenresult.data.refresh_token;

    // ํšŒ์›๊ฐ€์ž…  
    const authService = new AuthService();
    const newUser = await authService.signIn(snsUserId, snsRefreshToken);

    // jwtAccessToken, jwtRefreshToken ๋ฐœ๊ธ‰
    const accessToken = authService.generateAccessToken(newUser.id);
    const refreshToken = authService.generateRefreshToken();

    res.status(200).json({
      "accessToken": accessToken,
      "refreshToken": refreshToken,
    });

    
  } catch (e) {
    res.status(400).send(`err -> ${e}`);
  }
};

๋งˆ๋ฌด๋ฆฌ

  • ํด๋ผ์ด์–ธํŠธ(ios)๋Š” ๋ฐฑ์—”๋“œ๊ฐ€ ๋ณด๋‚ธ accessToken๊ณผ refreshToken์„  ๋‚ด๋ถ€์— ์ €์žฅํ•˜๊ณ  ์žˆ๋‹ค๊ฐ€ ์„œ๋น„์Šค apiํ˜ธ์ถœํ• ๋•Œ๋งˆ๋‹ค header์— ๋„ฃ์–ด์ค˜์„œ ์‚ฌ์šฉํ•˜๋ฉด ๋œ๋‹ค
  • accessToken์ด ๋งŒ๋ฃŒ๋˜์–ด 401์—๋Ÿฌ๋ฅผ ๋ณด๋‚ธ๋‹ค๋ฉด ํด๋ผ์ด์–ธํŠธ๋Š” ๊ฐ€์ง€๊ณ  ์žˆ๋Š” refreshToken์œผ๋กœ accessToken ์žฌ๋ฐœํ–‰!
  • ๋กœ๊ทธ์•„์›ƒ์ฒ˜๋ฆฌ๋Š” ํด๋ผ์ด์–ธํŠธ์—์„œ ๋‚ด๋ถ€์— ๊ฐ€์ง€๊ณ  ์žˆ๋Š” accessToken๊ณผ refreshToken์„ ๋ชจ๋‘ ๋งŒ๋ฃŒ์‹œํ‚ค๊ฑฐ๋‚˜ ์‚ญ์ œํ•˜๋Š”๊ฑธ๋กœ ๊ตฌํ˜„ํ•˜๋ฉด๋œ๋‹ค!

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

์ฐธ๊ณ ์ž๋ฃŒ

https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens

https://tlog.tammolo.com/posts/apple-login-02

https://developer111.tistory.com/58

https://hwannny.tistory.com/71