Sign in with Apple ํ์คํ(iOS + Node.js) ๊ฐ๋ฐ ๋ ธํธ
์ ํ๋ก๊ทธ์ธ๊ธฐ๋ฅ์ ํด๋ผ์ด์ธํธ(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