├── .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 | ![Image](https://tva1.sinaimg.cn/large/007S8ZIlgy1ge654d0fdrj319o0pqq9k.jpg) 16 | 17 | Service IDs 를 클릭후 다음으로 넘어갑니다. 18 | 19 | 20 | 21 | ![IMAGE](https://tva1.sinaimg.cn/large/007S8ZIlgy1ge65a6o7stj30zf0deaax.jpg) 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+""},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 \n \n \n \n \n 田욋��').concat(o,"\n \n \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})}); --------------------------------------------------------------------------------