From d4fd4a67e2a200b0d7c71df9c7871aaad1111e52 Mon Sep 17 00:00:00 2001 From: sb Date: Sat, 11 Jul 2026 10:41:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=A0=ED=94=8C=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20(Sign=20in=20with=20Apple)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @capacitor-community/apple-sign-in 으로 identityToken 획득 → /api/auth/apple. 애플 버튼은 네이티브 iOS 에서만 노출, 시트 취소는 무시. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 26 ++++++++++++++++++++++++++ package.json | 1 + src/lib/appleAuth.ts | 36 ++++++++++++++++++++++++++++++++++++ src/pages/LoginPage.vue | 31 +++++++++++++++++++++---------- 4 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 src/lib/appleAuth.ts diff --git a/package-lock.json b/package-lock.json index 37fdfab..143ff42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "dognation_pt", "version": "0.0.1", "dependencies": { + "@capacitor-community/apple-sign-in": "^7.1.0", "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.3.4", "@quasar/extras": "^1.16.12", @@ -83,6 +84,19 @@ "dev": true, "license": "(Apache-2.0 AND BSD-3-Clause)" }, + "node_modules/@capacitor-community/apple-sign-in": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@capacitor-community/apple-sign-in/-/apple-sign-in-7.1.0.tgz", + "integrity": "sha512-df7cA7vfvvvilTtpJKeVvxO31W+py5t3HK9233GBD5MvgAgtGvCqeQ3pI0noNkVsKPF9B1g/po8Mph6s4bVSmw==", + "license": "MIT", + "dependencies": { + "@types/scriptjs": "0.0.2", + "scriptjs": "^2.5.9" + }, + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, "node_modules/@capacitor/android": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.4.1.tgz", @@ -1560,6 +1574,12 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/scriptjs": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@types/scriptjs/-/scriptjs-0.0.2.tgz", + "integrity": "sha512-GFrUgzGYfNX3VWZwMyzr0JrBwzLv5Kes4BQBvo6hXdRy14/GBbpCiVwwB7v1o2xIEvFf+9GyznmYhuesQQjSag==", + "license": "MIT" + }, "node_modules/@types/slice-ansi": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", @@ -3191,6 +3211,12 @@ "dev": true, "license": "ISC" }, + "node_modules/scriptjs": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/scriptjs/-/scriptjs-2.5.9.tgz", + "integrity": "sha512-qGVDoreyYiP1pkQnbnFAUIS5AjenNwwQBdl7zeos9etl+hYKWahjRTfzAZZYBv5xNHx7vNKCmaLDQZ6Fr2AEXg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", diff --git a/package.json b/package.json index 9c8bb57..e4c813a 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "typecheck": "vue-tsc --noEmit" }, "dependencies": { + "@capacitor-community/apple-sign-in": "^7.1.0", "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.3.4", "@quasar/extras": "^1.16.12", diff --git a/src/lib/appleAuth.ts b/src/lib/appleAuth.ts new file mode 100644 index 0000000..97281d7 --- /dev/null +++ b/src/lib/appleAuth.ts @@ -0,0 +1,36 @@ +// Sign in with Apple — 네이티브(iOS) 전용 래퍼. +// @capacitor-community/apple-sign-in 플러그인으로 identityToken(JWT)을 받아온다. +// 이 토큰의 aud 는 앱 번들 ID(=capacitor appId)이며, 백엔드가 APPLE_CLIENT_ID 로 검증한다. +import { Capacitor } from '@capacitor/core' +import { SignInWithApple } from '@capacitor-community/apple-sign-in' + +// capacitor.config 의 appId 와 일치. iOS 네이티브 플로우의 aud 가 된다. +const APPLE_CLIENT_ID = 'kr.sblog.dognation' + +export interface AppleCredential { + identityToken: string + /** 애플은 최초 인증 시에만 이름을 준다. 이후에는 null. */ + name: string | null +} + +/** 현재 실행 환경이 네이티브(iOS)인지 — 애플 로그인 지원 여부. */ +export function isAppleSignInAvailable(): boolean { + return Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'ios' +} + +/** + * 네이티브 Sign in with Apple 을 실행하고 identityToken 을 반환한다. + * 사용자가 취소하면 플러그인이 reject 하므로 호출부에서 구분 처리한다. + */ +export async function signInWithApple(): Promise { + const { response } = await SignInWithApple.authorize({ + clientId: APPLE_CLIENT_ID, + // iOS 네이티브 플로우에서는 사용되지 않지만 타입상 필요. + redirectURI: `https://${APPLE_CLIENT_ID}/apple/callback`, + scopes: 'name email', + }) + // familyName + givenName (한국식). 최초 인증에만 제공되며, 없으면 null. + const name = + [response.familyName, response.givenName].filter(Boolean).join(' ').trim() || null + return { identityToken: response.identityToken, name } +} \ No newline at end of file diff --git a/src/pages/LoginPage.vue b/src/pages/LoginPage.vue index ea5856e..08dd81b 100644 --- a/src/pages/LoginPage.vue +++ b/src/pages/LoginPage.vue @@ -15,8 +15,9 @@ {{ googleError }} - + (null) const googleError = ref('') const appleLoading = ref(false) +const appleAvailable = isAppleSignInAvailable() const devLoading = ref(false) const isDev = import.meta.env.DEV @@ -117,16 +120,24 @@ function loadGsi(): Promise { } async function onApple() { - // 네이티브(iOS)에서는 Capacitor Sign in with Apple 플러그인으로 identityToken 을 받아 - // auth.loginApple(identityToken, name) 을 호출하도록 연동 예정. appleLoading.value = true - $q.notify({ - color: 'grey-8', - message: '애플 로그인은 네이티브(iOS) 플러그인 연동이 필요합니다.', - icon: 'mdi-apple', - position: 'top', - }) - appleLoading.value = false + try { + const { identityToken, name } = await signInWithApple() + await auth.loginApple(identityToken, name) + redirectAfterLogin() + } catch (e) { + // 사용자가 시트를 취소한 경우는 조용히 무시. + if (isAppleCancel(e)) return + notifyError(e) + } finally { + appleLoading.value = false + } +} + +/** 애플 로그인 시트 취소 판별 — 플러그인이 취소를 에러로 reject 한다. */ +function isAppleCancel(e: unknown): boolean { + const msg = (e instanceof Error ? e.message : String(e)).toLowerCase() + return msg.includes('cancel') || msg.includes('1001') || msg.includes('popup_closed') } async function onDevLogin() {