├── .gitignore
├── LICENSE
├── README.md
└── custom_apple.js
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript v1 declaration files
45 | typings/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.test
74 |
75 | # parcel-bundler cache (https://parceljs.org/)
76 | .cache
77 |
78 | # Next.js build output
79 | .next
80 |
81 | # Nuxt.js build / generate output
82 | .nuxt
83 | dist
84 |
85 | # Gatsby files
86 | .cache/
87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
88 | # https://nextjs.org/blog/next-9-1#public-directory-support
89 | # public
90 |
91 | # vuepress build output
92 | .vuepress/dist
93 |
94 | # Serverless directories
95 | .serverless/
96 |
97 | # FuseBox cache
98 | .fusebox/
99 |
100 | # DynamoDB Local files
101 | .dynamodb/
102 |
103 | # TernJS port file
104 | .tern-port
105 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 hmmhmmhm
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Sign With Apple 예제
2 |
3 | > 애플 로그인을 자바스크립트를 이용해서 구현할 때 겪었던 예외상황들을 기록합니다.
4 |
5 |
6 |
7 | ## 애플 페이지 상 설정
8 |
9 | > Sign In with App 웹용 설정 방법을 설명합니다.
10 |
11 | App Id 와는 별개로 Service ID 를 반드시 별개로 추가 생성해주어야합니다. 아래 적힌 주소로 이동한 후 Identifiers 카테고리에서 새로운 Service ID 의 추가를 진행합니다.
12 |
13 | https://developer.apple.com/account/resources/certificates/list
14 |
15 | 
16 |
17 | Service IDs 를 클릭후 다음으로 넘어갑니다.
18 |
19 |
20 |
21 | 
22 |
23 | Sign In with Apple 을 클릭한 후 Configure 에서 redirect-uri 등을 입력하여 등록한 후 Continue 를 눌러서 생성되는 .p8 파일를 받아놓은 후 Key Id 를 복사해놓습니다. (.p8 파일은 백엔드 서버에서 애플로 요청을 보낼때 사용됩니다.)
24 |
25 |
26 |
27 | ## 프론트엔드 설정
28 |
29 | > 사용하려면 먼저 HTML 상에 메타 태그를 입력해야합니다. (프론트엔드 라이브러리에 따라서 태그를 주입하는 형식으로 사용할 수도 있습니다.)
30 |
31 | 만약 팝업을 사용할 수 있다면 애플 로그인을 그냥 js 를 통해서 사용할 수 있겠지만, 하이브리드 앱의 경우엔 인앱 브라우저 기능을 통해서 해당 페이지를 띄우므로 인앱 브라우저 상에서 팝업 기능을 사용하는 것이 제한되는 경우가 많았습니다. 따라서 저는 아래와 같은 세팅으로 popup 을 false 처리 한 후 라이브러리를 이용하였습니다.
32 |
33 | ```html
34 |
35 |
36 |
37 |
38 | ```
39 |
40 |
41 |
42 | ### 라이브러리 커스터마이징
43 |
44 | > 기본 제공되는 sign with apple js 파일은 요청방식이 매우 특이합니다. 일단 최초에 애플 페이지로 이동되는 것은 정상적입니다, 그러나 해당 페이지로 이동된 다음 다시 위에서 HTML 태그를 통해서 `redirect-uri` 로 이동되지는 않습니다.
45 | >
46 | > 네이버 로그인이나 카카오 로그인처럼 원래 페이지로 리다이렉션 되는 것이 아닌, 해당 `redirect-uri` 로 `POST` 요청이 날아갑니다. 즉 sign-with-apple 의 `redirect-uri` 는 POST 요청이 날아갈 API 서버 상의 주소가 담겨야하는 것입니다.
47 | >
48 | > 이렇게 구현해도 되긴하지만, 저는 원래 uri 로 이동되면서 주소상으로 파라메터가 전달되는 구조의 API 가 필요했기 때문에 라이브러리를 커스터마이징 하였습니다.
49 |
50 | 애플로그인 라이브러리 파일을 다운받은 후 해당 파일을 열어서 아래내용을 검색합니다.
51 |
52 | ```js
53 | responseMode:"form_post"
54 | ```
55 |
56 | 만약 `redirect-uri` 상으로 POST 요청이 전송되는 것이 아닌, 리다이렉션 및 주소 파라메터를 통한 결과값 전달을 하고 싶은 경우 "form_post" 으로 해당 내용을 바꿔주세요.
57 |
58 | ```js
59 | responseMode:"fragment"
60 | ```
61 |
62 | > 만약 이러한 작업을 직접하기를 원하지 않는 경우 이 레포지스토리에 업로드 되어있는 `custom_apple.js` 파일을 다운받아서 사용하실 수 있습니다.
63 |
64 | 해당 내용에 대한 애플 문서상 설명은 여기에 서술되어 있습니다. [Send the Required Query Parameters](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms)
65 |
66 |
67 |
68 | ## 프론트엔드 API 사용 예시
69 |
70 | > Svelte 를 통해서 클라이어언트 상에서 Sign With Apple 을 구성한 코드의 일부 예시입니다.
71 |
72 | "fragment" 옵션을 통해서 파라메터로 입력된 애플 로그인 결과물은 querystring 모듈로 해석할 수 있습니다.
73 |
74 | ```js
75 | import * as AppleID from './custom_apple.js'
76 | import querystring from 'querystring'
77 |
78 | export let params: any = {}
79 | declare var window: any
80 |
81 |
82 | let randomId = params.randomId
83 | if (randomId !== undefined) {
84 | Page.cookieStorage.setItem('randomId', randomId)
85 | onMount(async () => {
86 | try {
87 | await AppleID.auth.signIn()
88 | } catch (error) {
89 | noticeText =
90 | '소셜로그인에 실패하였습니다.
앱으로 다시 돌아가주세요.'
91 | }
92 | })
93 | } else {
94 | onMount(async () => {
95 | let randomId = Page.getCacheItem('randomId')
96 |
97 | try {
98 | let callbackStr = window.location.href.split('#')[1]
99 | if(!callbackStr || callbackStr.length < 10){
100 | Page.Router.replace('/')
101 | return
102 | }
103 | let callbackData: {
104 | code: string
105 | id_token: string
106 | } = querystring.decode(callbackStr)
107 |
108 | let data = {
109 | randomId,
110 | token: JSON.stringify(callbackData),
111 | category: '애플',
112 | }
113 |
114 | try {
115 | await Page.GraphQLAPI.auth.socialLoginBrowserSave(data)
116 | noticeText =
117 | '소셜로그인이 완료되었습니다.
앱으로 다시 돌아가주세요.'
118 | } catch (err) {
119 | noticeText =
120 | '소셜로그인에 실패하였습니다.
앱으로 다시 돌아가주세요.'
121 | }
122 | } catch (e) {
123 | console.log(e)
124 | Page.Router.replace('/')
125 | }
126 | })
127 | }
128 | ```
129 |
130 |
131 |
132 | ## 백엔드 설정
133 |
134 | > 백엔드 구성도 만만치 않았습니다.
135 |
136 | ### p8 파일을 이용한 인증토큰 구성 예시
137 |
138 | 애플에서 받은 p8 파일이 필요합니다. 이 파일과 사용되는 TEAM_ID 와 KEY_ID 를 입력한 후 아래와 같은 함수를 구성합니다.
139 |
140 | TEAM_ID 는 애플 계정마다 하나씩 존재하며, 아래 URL 에서 확인할 수 있습니다.
141 | https://developer.apple.com/account/#/membership/
142 |
143 | ```js
144 | import * as fs from 'fs'
145 | import * as jwt from 'jsonwebtoken'
146 |
147 | const signWithApplePrivateKey = fs.readFileSync('./signwithapple.p8') // .P8 FILE PATH
148 | export const getSignWithAppleSecret = () => {
149 | const token = jwt.sign({}, signWithApplePrivateKey, {
150 | algorithm: 'ES256',
151 | expiresIn: '10h',
152 | audience: 'https://appleid.apple.com',
153 | issuer: 'A1B2CD3E4F', // TEAM_ID
154 | subject: 'com.yourapp.name',
155 | keyid: '123AB45C67', // KEY_ID
156 | })
157 | return token
158 | }
159 | ```
160 |
161 |
162 |
163 | ### 백엔드 애플 토큰 검증용 API 사용 예시
164 |
165 | > 위에서 구성한 getSignWithAppleSecret 를 임포트합니다.
166 |
167 | ```js
168 | import { getSignWithAppleSecret } from '../../updateAppleSecretKey'
169 | import * as querystring from 'querystring'
170 | import * as jwt from 'jsonwebtoken'
171 | import axios from 'axios'
172 | try {
173 | let parsedData: {
174 | code: string
175 | id_token: string
176 | } = JSON.parse(token)
177 | let response
178 | try {
179 | // console.log('idToken:', idToken)
180 | response = await axios.post(
181 | 'https://appleid.apple.com/auth/token',
182 | querystring.stringify({
183 | grant_type: 'authorization_code',
184 | code: parsedData.code,
185 | client_secret: getSignWithAppleSecret(),
186 | client_id: 'com.yourapp.name',
187 | redirect_uri: 'https://your-apple-auth-page.com',
188 | }),
189 | {
190 | headers: {
191 | 'Content-Type':
192 | 'application/x-www-form-urlencoded',
193 | },
194 | }
195 | )
196 | } catch (e) {
197 | console.log(e)
198 | }
199 |
200 | if (!response.data.id_token)
201 | throw new Error('토큰 인증에 실패하였습니다.')
202 |
203 | console.log(
204 | 'process.env.APPLE_SECRET',
205 | process.env.APPLE_SECRET
206 | )
207 |
208 | let userData: any = jwt.decode(parsedData.id_token)
209 | console.log('userData', userData)
210 |
211 | accountData.name = '이용자'
212 | try {
213 | accountData.name = userData.email.split('@')[0]
214 | } catch (e) {}
215 | accountData.tel = `apple_${userData.sub}`
216 | accountData.loginId = `apple_${userData.sub}`
217 | } catch (e) {
218 | console.log(e)
219 | }
220 | ```
221 |
222 |
223 |
224 | 위와 같은 방법을 통해서 JS 를 통한 애플로그인을 구현하실 수 있습니다.
--------------------------------------------------------------------------------
/custom_apple.js:
--------------------------------------------------------------------------------
1 | !function(e,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((e=e||self).AppleID={})}(this,function(e){"use strict";var n=function(e,n){var t=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"";t||(t="");var i="";for(var o in n)n.hasOwnProperty(o)&&(i+=" "+o+'="'+n[o]+'"');return"<"+e+i+">"+t+""+e+">"},t=function(e){var n="";for(var t in e)e[t]&&e.hasOwnProperty(t)&&(n+=" "+t+": "+e[t]+";");return n},i={"sign-in":{text:"Sign in with Apple",boundingBox:{x:0,y:-11,width:111.046875,height:14},fontFamily:"SF Pro Text",rtl:!1,letterSpacing:"-.022em"},continue:{text:"Continue with Apple",boundingBox:{x:0,y:-11,width:123.6875,height:14},fontFamily:"SF Pro Text",rtl:!1,letterSpacing:"-.022em"}},o=function(e){return"number"!=typeof e||isNaN(e)?"100%":Math.floor(e)+"px"},A=function(e){var A=e.color,r=void 0===A?"black":A,c=e.type,d=void 0===c?"sign-in":c,a=e.border,s=void 0!==a&&a,u=e.width,l=e.height,p=e.borderRadius,f=i[d],h=function(e){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"black",t=i[e],o=t.text,A=t.rtl,r=t.fontFamily,c=t.boundingBox,d=c.width,a=c.height,s=c.y,u=c.x;return'\n \n ")}(d,r),g=t({"font-synthesis":"none","-moz-font-feature-settings":"kern","-webkit-font-smoothing":"antialiased","-moz-osx-font-smoothing":"grayscale",width:o(u),height:o(l),"min-width":"130px","max-width":"375px","min-height":"30px","max-height":"64px",position:"relative"});return n("div",{style:g,role:"button",tabindex:"0","aria-label":f.text},"\n ".concat(n("div",{style:t({"padding-right":"8%","padding-left":"8%",position:"absolute","box-sizing":"border-box",width:"100%",height:"100%"})},h),"\n ").concat(n("div",{style:t({padding:s?"1px":void 0,width:"100%",height:"100%","box-sizing":"border-box"})},function(e){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:15,t=arguments.length>2&&void 0!==arguments[2]&&arguments[2];return'\n \n \n ")}(r,p,s)),"\n "))},r=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=e.id,t=void 0===n?"appleid-button":n,i=e.color,o=void 0===i?"black":i,r=e.type,c=void 0===r?"sign-in":r,d=e.border,a=void 0!==d&&d,s=e.width,u=void 0===s?"100%":s,l=e.height,p=void 0===l?"100%":l,f=e.borderRadius;!function(e,n){var t=document.getElementById(e);if(null!==t)t.innerHTML=n}(t,A({color:o,type:c,border:a,width:u,height:p,borderRadius:void 0===f?15:f}))},c=["0","0"],d=function(){for(var e={},n=0;n1&&void 0!==arguments[1])||arguments[1];K()&&V({error:u}),j=n,"2"!==c[1]&&(c[1]="1");var t=v(e);c[1]="0";var i,o,A=!!window.Promise;if(e.usePopup){if(n&&!A)throw new Error("Promise is required to use popup, please use polyfill.");if(R(t)){if(H=!0,n)return o=G(),Z=o}else if(T(g,{error:p}),n)return Promise.reject({error:p})}else i=t,window.location.assign(i)},q=function(e){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:s;["scope","state","nonce","usePopup"].forEach(function(t){if(e[t])if("usePopup"===t){if("boolean"!=typeof e[t])throw new Error('The "'+t+'" should be boolean.');n[t]=e[t]}else{if("string"!=typeof e[t])throw new Error('The "'+t+'" should be a string.');n.client[t]=e[t]}})},_=function(){var e,n,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null,i=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],o=s;if(!a.isInit)throw new Error('The "init" function must be called first.');if(t){if(!(t instanceof Object)||Array.isArray(t))throw new Error('The "signinConfig" must be "object".');e=t,(n=Object.create(s)).client=Object.create(s.client),e.scope&&"string"==typeof e.scope&&(n.client.scope=e.scope),e.redirectURI&&"string"==typeof e.redirectURI&&(n.client.redirectURI=e.redirectURI),q(t,o=n)}return W(o,i)},$=function(e){if(!e.clientId||"string"!=typeof e.clientId)throw new Error('The "clientId" should be a string.');if(s.client.clientId=e.clientId,!e.redirectURI||"string"!=typeof e.redirectURI)throw new Error('The "redirectURI" should be a string.');s.client.redirectURI=e.redirectURI,q(e),oe(),a.isInit=!0},ee=function(){c[1]="2",_(null,!1)},ne=function(){ee()},te=function(e){32===e.keyCode?e.preventDefault():13===e.keyCode&&(e.preventDefault(),ee())},ie=function(e){32===e.keyCode&&(e.preventDefault(),ee())},oe=function(){var e,n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=document.getElementById("appleid-signin");if(t){(e=t)&&e.firstChild&&e.removeChild(e.firstChild);var i=function(e){var n,t,i=e.dataset,o="black",A=!0,r="sign-in",c=15;return null!=i&&(i.color&&(o=i.color),i.border&&(A="false"!==i.border),i.type&&(r=i.type),i.borderRadius&&!isNaN(parseInt(i.borderRadius,10))&&(c=parseInt(i.borderRadius,10)),i.width&&!isNaN(parseInt(i.width,10))&&(n=parseInt(i.width,10)),i.height&&!isNaN(parseInt(i.height,10))&&(t=parseInt(i.height,10))),"sign in"===r&&(r="sign-in"),{color:o,border:A,type:r,borderRadius:c,width:n,height:t}}(t);r(d({id:"appleid-signin"},i,n)),t.addEventListener("click",ne),t.addEventListener("keydown",te),t.addEventListener("keyup",ie)}};!function(e){e.ClientId="appleid-signin-client-id",e.Scope="appleid-signin-scope",e.RedirectURI="appleid-signin-redirect-uri",e.State="appleid-signin-state",e.Nonce="appleid-signin-nonce",e.UsePopup="appleid-signin-use-popup",e.DEV_URI="appleid-signin-dev-uri",e.DEV_ENV="appleid-signin-dev-env",e.DEV_PATH="appleid-signin-dev-path"}(Q||(Q={}));var Ae,re=function(){if(!J){J={};for(var e=function(){var e={};return Object.keys(Q).forEach(function(n){return e[Q[n]]=!0}),e}(),n=document.getElementsByTagName("meta"),t="",i=0;i0&&void 0!==arguments[0]?arguments[0]:null;return _(e)},renderButton:oe},ae=function(){if(n=re(),Object.keys(n).length>0){"1"===c[0]?c[0]="4":c[0]="2";var e=function(){var e={clientId:"",scope:"",redirectURI:"",state:"",nonce:""},n=re();n[Q.ClientId]&&(e.clientId=n[Q.ClientId]),n[Q.Scope]&&(e.scope=n[Q.Scope]),n[Q.RedirectURI]&&(e.redirectURI=n[Q.RedirectURI]),n[Q.State]&&(e.state=n[Q.State]),n[Q.Nonce]&&(e.nonce=n[Q.Nonce]),n[Q.UsePopup]&&(e.usePopup="true"===n[Q.UsePopup]);var t=n[Q.DEV_ENV],i=n[Q.DEV_PATH],o=n[Q.DEV_URI];return(t||i||o)&&(t&&(s.env=t),i&&(s.path=i),o&&(s.baseURI=o,N(s))),e}();$(d({},e,ce))}var n};"complete"===document.readyState||"loaded"===document.readyState||"interactive"===document.readyState?ae():document.addEventListener("DOMContentLoaded",function(){ae()}),Ae=f,setTimeout(function(){return T(Ae)}),e.auth=de,Object.defineProperty(e,"__esModule",{value:!0})});
--------------------------------------------------------------------------------